Merge branch 'multi-device' of https://github.com/loki-project/loki-messenger into multidevice-publicchat

pull/539/head
Ryan Tharp 6 years ago
commit 7b22806bf0

@ -0,0 +1,58 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Mocha Tests",
"program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
"args": [
"--recursive",
"--exit",
"test/app",
"test/modules",
"ts/test",
"libloki/test/node"
],
"internalConsoleOptions": "openOnSessionStart"
},
{
"type": "node",
"request": "launch",
"name": "Launch node Program",
"program": "${file}"
},
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${file}"
},
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}"
},
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"env": {
"NODE_APP_INSTANCE": "1"
},
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"args": ["."],
"sourceMaps": true,
"outputCapture": "std"
}
]
}

@ -926,6 +926,30 @@
"cancel": {
"message": "Cancel"
},
"skip": {
"message": "Skip"
},
"close": {
"message": "Close"
},
"pairNewDevice": {
"message": "Pair new Device"
},
"devicePairingAccepted": {
"message": "Device Pairing Accepted"
},
"devicePairingReceived": {
"message": "Device Pairing Received"
},
"waitingForDeviceToRegister": {
"message": "Waiting for device to register..."
},
"pairedDevices": {
"message": "Paired Devices"
},
"allowPairing": {
"message": "Allow Pairing"
},
"clear": {
"message": "Clear"
},

@ -73,6 +73,14 @@ module.exports = {
removeContactSignedPreKeyByIdentityKey,
removeAllContactSignedPreKeys,
createOrUpdatePairingAuthorisation,
removePairingAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getGrantAuthorisationsForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceFor,
getPairedDevicesFor,
createOrUpdateItem,
getItemById,
getAllItems,
@ -100,13 +108,15 @@ module.exports = {
updateConversation,
removeConversation,
getAllConversations,
getConversationsWithFriendStatus,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getPubKeysWithFriendStatus,
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
removeAllConversations,
removeAllPrivateConversations,
searchConversations,
searchMessages,
@ -780,7 +790,10 @@ async function updateSchema(instance) {
await updateLokiSchema(instance);
}
const LOKI_SCHEMA_VERSIONS = [updateToLokiSchemaVersion1];
const LOKI_SCHEMA_VERSIONS = [
updateToLokiSchemaVersion1,
updateToLokiSchemaVersion2,
];
async function updateToLokiSchemaVersion1(currentVersion, instance) {
if (currentVersion >= 1) {
@ -914,6 +927,34 @@ async function updateToLokiSchemaVersion1(currentVersion, instance) {
console.log('updateToLokiSchemaVersion1: success!');
}
async function updateToLokiSchemaVersion2(currentVersion, instance) {
if (currentVersion >= 2) {
return;
}
console.log('updateToLokiSchemaVersion2: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run(
`CREATE TABLE pairingAuthorisations(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
primaryDevicePubKey VARCHAR(255),
secondaryDevicePubKey VARCHAR(255),
isGranted BOOLEAN,
json TEXT
);`
);
await instance.run(
`INSERT INTO loki_schema (
version
) values (
2
);`
);
await instance.run('COMMIT TRANSACTION;');
console.log('updateToLokiSchemaVersion2: success!');
}
async function updateLokiSchema(instance) {
const result = await instance.get(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name='loki_schema';"
@ -1271,7 +1312,7 @@ async function getContactSignedPreKeyById(id) {
}
async function getContactSignedPreKeyByIdentityKey(key) {
const row = await db.get(
`SELECT * FROM ${CONTACT_SIGNED_PRE_KEYS_TABLE} WHERE identityKeyString = $identityKeyString;`,
`SELECT * FROM ${CONTACT_SIGNED_PRE_KEYS_TABLE} WHERE identityKeyString = $identityKeyString ORDER BY keyId DESC;`,
{
$identityKeyString: key,
}
@ -1331,6 +1372,114 @@ async function removeAllSignedPreKeys() {
return removeAllFromTable(SIGNED_PRE_KEYS_TABLE);
}
const PAIRING_AUTHORISATIONS_TABLE = 'pairingAuthorisations';
async function getAuthorisationForSecondaryPubKey(pubKey, options) {
const granted = options && options.granted;
let filter = '';
if (granted) {
filter = 'AND isGranted = 1';
}
const row = await db.get(
`SELECT json FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey ${filter};`,
{
$secondaryDevicePubKey: pubKey,
}
);
if (!row) {
return null;
}
return jsonToObject(row.json);
}
async function getGrantAuthorisationsForPrimaryPubKey(primaryDevicePubKey) {
const rows = await db.all(
`SELECT json FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE primaryDevicePubKey = $primaryDevicePubKey AND isGranted = 1 ORDER BY secondaryDevicePubKey ASC;`,
{
$primaryDevicePubKey: primaryDevicePubKey,
}
);
return map(rows, row => jsonToObject(row.json));
}
async function createOrUpdatePairingAuthorisation(data) {
const { primaryDevicePubKey, secondaryDevicePubKey, grantSignature } = data;
await db.run(
`INSERT OR REPLACE INTO ${PAIRING_AUTHORISATIONS_TABLE} (
primaryDevicePubKey,
secondaryDevicePubKey,
isGranted,
json
) values (
$primaryDevicePubKey,
$secondaryDevicePubKey,
$isGranted,
$json
)`,
{
$primaryDevicePubKey: primaryDevicePubKey,
$secondaryDevicePubKey: secondaryDevicePubKey,
$isGranted: Boolean(grantSignature),
$json: objectToJSON(data),
}
);
}
async function removePairingAuthorisationForSecondaryPubKey(pubKey) {
await db.run(
`DELETE FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey;`,
{
$secondaryDevicePubKey: pubKey,
}
);
}
async function getSecondaryDevicesFor(primaryDevicePubKey) {
const authorisations = await getGrantAuthorisationsForPrimaryPubKey(
primaryDevicePubKey
);
return map(authorisations, row => row.secondaryDevicePubKey);
}
async function getPrimaryDeviceFor(secondaryDevicePubKey) {
const row = await db.get(
`SELECT primaryDevicePubKey FROM ${PAIRING_AUTHORISATIONS_TABLE} WHERE secondaryDevicePubKey = $secondaryDevicePubKey AND isGranted = 1;`,
{
$secondaryDevicePubKey: secondaryDevicePubKey,
}
);
if (!row) {
return null;
}
return row.primaryDevicePubKey;
}
// Return all the paired pubkeys for a specific pubkey (excluded),
// irrespective of their Primary or Secondary status.
async function getPairedDevicesFor(pubKey) {
let results = [];
// get primary pubkey (only works if the pubkey is a secondary pubkey)
const primaryPubKey = await getPrimaryDeviceFor(pubKey);
if (primaryPubKey) {
results.push(primaryPubKey);
}
// get secondary pubkeys (only works if the pubkey is a primary pubkey)
const secondaryPubKeys = await getSecondaryDevicesFor(
primaryPubKey || pubKey
);
results = results.concat(secondaryPubKeys);
// ensure the input pubkey is not in the results
results = results.filter(x => x !== pubKey);
return results;
}
const ITEMS_TABLE = 'items';
async function createOrUpdateItem(data) {
return createOrUpdate(ITEMS_TABLE, data);
@ -1497,12 +1646,13 @@ async function getSwarmNodesByPubkey(pubkey) {
return jsonToObject(row.json).swarmNodes;
}
const CONVERSATIONS_TABLE = 'conversations';
async function getConversationCount() {
const row = await db.get('SELECT count(*) from conversations;');
const row = await db.get(`SELECT count(*) from ${CONVERSATIONS_TABLE};`);
if (!row) {
throw new Error(
'getConversationCount: Unable to get count of conversations'
`getConversationCount: Unable to get count of ${CONVERSATIONS_TABLE}`
);
}
@ -1522,7 +1672,7 @@ async function saveConversation(data) {
} = data;
await db.run(
`INSERT INTO conversations (
`INSERT INTO ${CONVERSATIONS_TABLE} (
id,
json,
@ -1586,7 +1736,7 @@ async function updateConversation(data) {
} = data;
await db.run(
`UPDATE conversations SET
`UPDATE ${CONVERSATIONS_TABLE} SET
json = $json,
active_at = $active_at,
@ -1612,7 +1762,9 @@ async function updateConversation(data) {
async function removeConversation(id) {
if (!Array.isArray(id)) {
await db.run('DELETE FROM conversations WHERE id = $id;', { $id: id });
await db.run(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`, {
$id: id,
});
return;
}
@ -1622,7 +1774,7 @@ async function removeConversation(id) {
// Our node interface doesn't seem to allow you to replace one single ? with an array
await db.run(
`DELETE FROM conversations WHERE id IN ( ${id
`DELETE FROM ${CONVERSATIONS_TABLE} WHERE id IN ( ${id
.map(() => '?')
.join(', ')} );`,
id
@ -1662,9 +1814,12 @@ async function getPublicServerTokenByServerUrl(serverUrl) {
}
async function getConversationById(id) {
const row = await db.get('SELECT * FROM conversations WHERE id = $id;', {
$id: id,
});
const row = await db.get(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE id = $id;`,
{
$id: id,
}
);
if (!row) {
return null;
@ -1674,30 +1829,35 @@ async function getConversationById(id) {
}
async function getAllConversations() {
const rows = await db.all('SELECT json FROM conversations ORDER BY id ASC;');
const rows = await db.all(
`SELECT json FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;`
);
return map(rows, row => jsonToObject(row.json));
}
async function getPubKeysWithFriendStatus(status) {
async function getConversationsWithFriendStatus(status) {
const rows = await db.all(
`SELECT id FROM conversations WHERE
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE
friendRequestStatus = $status
AND type = 'private'
ORDER BY id ASC;`,
{
$status: status,
}
);
return map(rows, row => row.id);
return map(rows, row => jsonToObject(row.json));
}
async function getAllConversationIds() {
const rows = await db.all('SELECT id FROM conversations ORDER BY id ASC;');
const rows = await db.all(
`SELECT id FROM ${CONVERSATIONS_TABLE} ORDER BY id ASC;`
);
return map(rows, row => row.id);
}
async function getAllPrivateConversations() {
const rows = await db.all(
`SELECT json FROM conversations WHERE
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
type = 'private'
ORDER BY id ASC;`
);
@ -1742,7 +1902,7 @@ async function getPublicConversationsByServer(server) {
async function getAllGroupsInvolvingId(id) {
const rows = await db.all(
`SELECT json FROM conversations WHERE
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
type = 'group' AND
members LIKE $id
ORDER BY id ASC;`,
@ -1756,7 +1916,7 @@ async function getAllGroupsInvolvingId(id) {
async function searchConversations(query, { limit } = {}) {
const rows = await db.all(
`SELECT json FROM conversations WHERE
`SELECT json FROM ${CONVERSATIONS_TABLE} WHERE
(
id LIKE $id OR
name LIKE $name OR
@ -2504,6 +2664,14 @@ async function removeAllConfiguration() {
await promise;
}
async function removeAllConversations() {
await removeAllFromTable(CONVERSATIONS_TABLE);
}
async function removeAllPrivateConversations() {
await db.run(`DELETE FROM ${CONVERSATIONS_TABLE} WHERE type = 'private'`);
}
async function getMessagesNeedingUpgrade(limit, { maxVersion }) {
const rows = await db.all(
`SELECT json FROM messages

@ -246,6 +246,52 @@
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='device-pairing-dialog'>
<div class="content">
<!-- Default view -->
<div class="defaultView">
<h4>{{ defaultTitle }}</h4>
<ul id="pairedPubKeys">
<li>No paired devices</li>
</ul>
<div class='buttons'>
<button id='close' tabindex='2'>{{ closeText }}</button>
<button id="startPairing" tabindex='1'>{{ startPairingText }}</button>
</div>
</div>
<!-- Waiting for request -->
<div class="waitingForRequestView" style="display: none;">
<h4>{{ waitingForRequestTitle }}</h4>
<div class='buttons'>
<button class="cancel">{{ cancelText }}</button>
</div>
</div>
<!-- Request Received -->
<div class="requestReceivedView" style="display: none;">
<h4>{{ requestReceivedTitle }}</h4>
Please verify that the secret words shown below matches the ones on your other device!
<p class="secretWords"></p>
<div class='buttons'>
<button class="skip">{{ skipText }}</button>
<button id="allowPairing">{{ allowPairingText }}</button>
</div>
</div>
<!-- Request accepted -->
<div class="requestAcceptedView" style="display: none;">
<h4>{{ requestAcceptedTitle }}</h4>
<p class="secretWords"></p>
<p class="transmissionStatus">Please be patient...</p>
<div class='buttons'>
<button class="ok" style="display: none;">{{ okText }}</button>
</div>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='beta-disclaimer-dialog'>
<div class="content">
<div class="betaDisclaimerView" style="display: none;">
@ -582,21 +628,7 @@
<!-- Registration -->
<div class='page'>
<h4 class='section-toggle'>Restore using seed</h4>
<div class='standalone-mnemonic section-content'>
<div class='standalone-mnemonic-inputs'>
<input class='form-control' type='text' id='mnemonic' placeholder='Mnemonic Seed' autocomplete='off' spellcheck='false' />
</div>
<div id='error' class='collapse'></div>
<div class='restore standalone-register-language'>
<span>Language:</span>
<div class='select-container'>
<select id='mnemonic-language'></select>
</div>
</div>
<a class='button' id='register-mnemonic'>Restore</a>
<div id=status></div>
</div>
<!-- New account -->
<h4 class='section-toggle section-toggle-visible'>Register a new account</h4>
<div class='standalone-register section-content'>
<div class='standalone-register-warning'>
@ -617,6 +649,40 @@
<a class='button' id='register' data-loading-text='Please wait...'>Register</a>
</div>
</div>
<!-- Restore using seed -->
<h4 class='section-toggle'>Restore using seed</h4>
<div class='standalone-mnemonic section-content'>
<div class='standalone-mnemonic-inputs'>
<input class='form-control' type='text' id='mnemonic' placeholder='Mnemonic Seed' autocomplete='off' spellcheck='false' />
</div>
<div id='error' class='collapse'></div>
<div class='restore standalone-register-language'>
<span>Language:</span>
<div class='select-container'>
<select id='mnemonic-language'></select>
</div>
</div>
<a class='button' id='register-mnemonic'>Restore</a>
<div id=status></div>
</div>
<!-- Link device to an existing account -->
<h4 class='section-toggle'>Link device to an existing account</h4>
<div class='standalone-secondary-device section-content'>
<p class='standalone-register-warning'>
You will need your Primary Device handy during this step.
</br>
Open the Loki Messenger App on your Primary Device
</br>
and select "Device Pairing" in the main menu.
</p>
<div class='standalone-secondary-device-inputs'>
<input class='form-control' type='text' id='primary-pubkey' placeholder='Primary Account Public Key' autocomplete='off' spellcheck='false' />
</div>
<div id='pubkey' class='collapse'></div>
<div id='error' class='collapse'></div>
<button type='button' class='button' id='register-secondary-device'>Link</button>
<button type='button' class='button' id='cancel-secondary-device' style="display: none;">Cancel</button>
</div>
</div>
<!-- Profile -->
@ -702,6 +768,7 @@
<script type='text/javascript' src='js/views/app_view.js'></script>
<script type='text/javascript' src='js/views/import_view.js'></script>
<script type='text/javascript' src='js/views/clear_data_view.js'></script>
<script type='text/javascript' src='js/views/device_pairing_dialog_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>

@ -172,6 +172,8 @@
return -1;
};
Whisper.events = _.clone(Backbone.Events);
Whisper.events.isListenedTo = eventName =>
Whisper.events._events ? !!Whisper.events._events[eventName] : false;
let accountManager;
window.getAccountManager = () => {
if (!accountManager) {
@ -182,6 +184,7 @@
const user = {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
isSecondaryDevice: !!textsecure.storage.get('isSecondaryDevice'),
};
Whisper.events.trigger('userChanged', user);
@ -205,7 +208,11 @@
window.log.info('Storage fetch');
storage.fetch();
let specialConvInited = false;
const initSpecialConversations = async () => {
if (specialConvInited) {
return;
}
const rssFeedConversations = await window.Signal.Data.getAllRssFeedConversations(
{
ConversationCollection: Whisper.ConversationCollection,
@ -223,9 +230,14 @@
// weird but create the object and does everything we need
conversation.getPublicSendData();
});
specialConvInited = true;
};
let initialisedAPI = false;
const initAPIs = async () => {
if (initialisedAPI) {
return;
}
const ourKey = textsecure.storage.user.getNumber();
window.feeds = [];
window.lokiMessageAPI = new window.LokiMessageAPI(ourKey);
@ -242,6 +254,11 @@
});
window.lokiP2pAPI.on('online', ConversationController._handleOnline);
window.lokiP2pAPI.on('offline', ConversationController._handleOffline);
initialisedAPI = true;
if (storage.get('isSecondaryDevice')) {
window.lokiFileServerAPI.updateOurDeviceMapping();
}
};
function mapOldThemeToNew(theme) {
@ -259,6 +276,9 @@
}
function startLocalLokiServer() {
if (window.localLokiServer) {
return;
}
const pems = window.getSelfSignedCert();
window.localLokiServer = new window.LocalLokiServer(pems);
}
@ -628,7 +648,10 @@
if (Whisper.Import.isIncomplete()) {
window.log.info('Import was interrupted, showing import error screen');
appView.openImporter();
} else if (Whisper.Registration.everDone()) {
} else if (
Whisper.Registration.isDone() &&
!Whisper.Registration.ongoingSecondaryDeviceRegistration()
) {
// listeners
Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion);
// window.Signal.RefreshSenderCertificate.initialize({
@ -756,6 +779,12 @@
}
});
Whisper.events.on('showDevicePairingDialog', async () => {
if (appView) {
appView.showDevicePairingDialog();
}
});
Whisper.events.on('calculatingPoW', ({ pubKey, timestamp }) => {
try {
const conversation = ConversationController.get(pubKey);
@ -791,6 +820,22 @@
appView.inboxView.trigger('password-updated');
}
});
Whisper.events.on('devicePairingRequestAccepted', async (pubKey, cb) => {
try {
await getAccountManager().authoriseSecondaryDevice(pubKey);
cb(null);
} catch (e) {
cb(e);
}
});
Whisper.events.on('devicePairingRequestRejected', async pubKey => {
await window.libloki.storage.removeContactPreKeyBundle(pubKey);
await window.libloki.storage.removePairingAuthorisationForSecondaryPubKey(
pubKey
);
});
}
window.getSyncRequest = () =>
@ -836,14 +881,14 @@
);
}
function disconnect() {
async function disconnect() {
window.log.info('disconnect');
// Clear timer, since we're only called when the timer is expired
disconnectTimer = null;
if (messageReceiver) {
messageReceiver.close();
await messageReceiver.close();
}
window.Signal.AttachmentDownloads.stop();
}
@ -873,7 +918,7 @@
}
if (messageReceiver) {
messageReceiver.close();
await messageReceiver.close();
}
const USERNAME = storage.get('number_id');
@ -888,6 +933,26 @@
Whisper.Notifications.disable(); // avoid notification flood until empty
if (Whisper.Registration.ongoingSecondaryDeviceRegistration()) {
const ourKey = textsecure.storage.user.getNumber();
window.lokiMessageAPI = new window.LokiMessageAPI(ourKey);
window.localLokiServer = null;
window.lokiPublicChatAPI = null;
window.feeds = [];
messageReceiver = new textsecure.MessageReceiver(
USERNAME,
PASSWORD,
mySignalingKey,
options
);
messageReceiver.addEventListener('message', onMessageReceived);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
PASSWORD
);
return;
}
// initialize the socket and start listening for messages
startLocalLokiServer();
await initAPIs();
@ -1075,7 +1140,7 @@
ev.confirm();
}
function onTyping(ev) {
async function onTyping(ev) {
const { typing, sender, senderDevice } = ev;
const { groupId, started } = typing || {};
@ -1084,7 +1149,17 @@
return;
}
const conversation = ConversationController.get(groupId || sender);
let primaryDevice = null;
const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey(
sender
);
if (authorisation) {
primaryDevice = authorisation.primaryDevicePubKey;
}
const conversation = ConversationController.get(
groupId || primaryDevice || sender
);
if (conversation) {
conversation.notifyTyping({
@ -1148,12 +1223,19 @@
}
}
// Do not set name to allow working with lokiProfile and nicknames
conversation.set({
name: details.name,
// name: details.name,
color: details.color,
active_at: activeAt,
});
await conversation.setLokiProfile({ displayName: details.name });
if (details.nickname) {
await conversation.setNickname(details.nickname);
}
// Update the conversation avatar only if new avatar exists and hash differs
const { avatar } = details;
if (avatar && avatar.data) {
@ -1298,6 +1380,14 @@
const messageDescriptor = getMessageDescriptor(data);
// Funnel messages to primary device conversation if multi-device
const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey(
messageDescriptor.id
);
if (authorisation) {
messageDescriptor.id = authorisation.primaryDevicePubKey;
}
const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags;
// eslint-disable-next-line no-bitwise
const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE);

@ -3,7 +3,8 @@
Whisper,
ConversationController,
MessageController,
_
_,
libloki,
*/
/* eslint-disable more/no-then */
@ -34,6 +35,15 @@
if (messages.length === 0) {
return null;
}
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
source
);
if (authorisation) {
// eslint-disable-next-line no-param-reassign
source = authorisation.primaryDevicePubKey;
}
const message = messages.find(
item => !item.isIncoming() && source === item.get('conversationId')
);

@ -10,7 +10,8 @@
Signal,
textsecure,
Whisper,
clipboard
clipboard,
libloki,
*/
/* eslint-disable more/no-then */
@ -443,6 +444,8 @@
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
const pubKey = this.get('conversationId');
await libloki.storage.saveAllPairingAuthorisationsFor(pubKey);
conversation.onAcceptFriendRequest();
},
async declineFriendRequest() {
@ -931,6 +934,7 @@
// that contact. Otherwise, it will be a standalone entry.
const errors = _.reject(allErrors, error => Boolean(error.number));
const errorsGroupedById = _.groupBy(allErrors, 'number');
const primaryDevicePubKey = this.get('conversationId');
const finalContacts = (phoneNumbers || []).map(id => {
const errorsForContact = errorsGroupedById[id];
const isOutgoingKeyError = Boolean(
@ -940,12 +944,20 @@
storage.get('unidentifiedDeliveryIndicators') &&
this.isUnidentifiedDelivery(id, unidentifiedLookup);
const isPrimaryDevice = id === primaryDevicePubKey;
const contact = this.findAndFormatContact(id);
const profileName = isPrimaryDevice
? contact.profileName
: `${contact.profileName} (Secondary Device)`;
return {
...this.findAndFormatContact(id),
...contact,
status: this.getStatus(id),
errors: errorsForContact,
isOutgoingKeyError,
isUnidentifiedDelivery,
isPrimaryDevice,
profileName,
onSendAnyway: () =>
this.trigger('force-send', {
contact: this.findContact(id),
@ -960,7 +972,8 @@
// first; otherwise it's alphabetical
const sortedContacts = _.sortBy(
finalContacts,
contact => `${contact.errors ? '0' : '1'}${contact.title}`
contact =>
`${contact.isPrimaryDevice ? '0' : '1'}${contact.phoneNumber}`
);
return {
@ -1760,7 +1773,7 @@
return message;
},
handleDataMessage(initialMessage, confirm) {
async handleDataMessage(initialMessage, confirm) {
// This function is called from the background script in a few scenarios:
// 1. on an incoming message
// 2. on a sent message sync'd from another device
@ -1770,9 +1783,15 @@
const source = message.get('source');
const type = message.get('type');
let conversationId = message.get('conversationId');
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
source
);
if (initialMessage.group) {
conversationId = initialMessage.group.id;
} else if (authorisation) {
conversationId = authorisation.primaryDevicePubKey;
}
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const conversation = ConversationController.get(conversationId);
@ -2023,10 +2042,7 @@
}
);
}
} else if (
source !== textsecure.storage.user.getNumber() &&
dataMessage.profile
) {
} else if (dataMessage.profile) {
ConversationController.getOrCreateAndWait(source, 'private').then(
sender => {
sender.setLokiProfile(dataMessage.profile);

@ -1,2 +1,3 @@
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;
export function getPrimaryDeviceFor(pubKey: string): Promise<string | null>;

@ -1,4 +1,4 @@
/* global window, setTimeout, IDBKeyRange */
/* global window, setTimeout, IDBKeyRange, dcodeIO */
const electron = require('electron');
@ -12,6 +12,7 @@ const {
merge,
set,
omit,
isArrayBuffer,
} = require('lodash');
const { base64ToArrayBuffer, arrayBufferToBase64 } = require('./crypto');
@ -88,6 +89,15 @@ module.exports = {
removeContactSignedPreKeyByIdentityKey,
removeAllContactSignedPreKeys,
createOrUpdatePairingAuthorisation,
removePairingAuthorisationForSecondaryPubKey,
getGrantAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getGrantAuthorisationsForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceFor,
getPairedDevicesFor,
createOrUpdateItem,
getItemById,
getAllItems,
@ -116,6 +126,7 @@ module.exports = {
getAllConversations,
getPubKeysWithFriendStatus,
getConversationsWithFriendStatus,
getAllConversationIds,
getAllPrivateConversations,
getAllRssFeedConversations,
@ -177,6 +188,8 @@ module.exports = {
removeAll,
removeAllConfiguration,
removeAllConversations,
removeAllPrivateConversations,
removeOtherData,
cleanupOrphanedAttachments,
@ -576,6 +589,63 @@ async function removeAllContactSignedPreKeys() {
await channels.removeAllContactSignedPreKeys();
}
function signatureToBase64(signature) {
if (signature.constructor === dcodeIO.ByteBuffer) {
return dcodeIO.ByteBuffer.wrap(signature).toString('base64');
} else if (isArrayBuffer(signature)) {
return arrayBufferToBase64(signature);
} else if (typeof signature === 'string') {
// assume it's already base64
return signature;
}
throw new Error(
'Invalid signature provided in createOrUpdatePairingAuthorisation. Needs to be either ArrayBuffer or ByteBuffer.'
);
}
async function createOrUpdatePairingAuthorisation(data) {
const { requestSignature, grantSignature } = data;
return channels.createOrUpdatePairingAuthorisation({
...data,
requestSignature: signatureToBase64(requestSignature),
grantSignature: grantSignature ? signatureToBase64(grantSignature) : null,
});
}
async function removePairingAuthorisationForSecondaryPubKey(pubKey) {
if (!pubKey) {
return;
}
await channels.removePairingAuthorisationForSecondaryPubKey(pubKey);
}
async function getGrantAuthorisationForSecondaryPubKey(pubKey) {
return channels.getAuthorisationForSecondaryPubKey(pubKey, {
granted: true,
});
}
async function getGrantAuthorisationsForPrimaryPubKey(pubKey) {
return channels.getGrantAuthorisationsForPrimaryPubKey(pubKey);
}
function getAuthorisationForSecondaryPubKey(pubKey) {
return channels.getAuthorisationForSecondaryPubKey(pubKey);
}
function getSecondaryDevicesFor(primaryDevicePubKey) {
return channels.getSecondaryDevicesFor(primaryDevicePubKey);
}
function getPrimaryDeviceFor(secondaryDevicePubKey) {
return channels.getPrimaryDeviceFor(secondaryDevicePubKey);
}
function getPairedDevicesFor(pubKey) {
return channels.getPairedDevicesFor(pubKey);
}
// Items
const ITEM_KEYS = {
@ -730,8 +800,20 @@ async function _removeConversations(ids) {
await channels.removeConversation(ids);
}
async function getConversationsWithFriendStatus(
status,
{ ConversationCollection }
) {
const conversations = await channels.getConversationsWithFriendStatus(status);
const collection = new ConversationCollection();
collection.add(conversations);
return collection;
}
async function getPubKeysWithFriendStatus(status) {
return channels.getPubKeysWithFriendStatus(status);
const conversations = await getConversationsWithFriendStatus(status);
return conversations.map(row => row.id);
}
async function getAllConversations({ ConversationCollection }) {
@ -1108,6 +1190,14 @@ async function removeAllConfiguration() {
await channels.removeAllConfiguration();
}
async function removeAllConversations() {
await channels.removeAllConversations();
}
async function removeAllPrivateConversations() {
await channels.removeAllPrivateConversations();
}
async function cleanupOrphanedAttachments() {
await callChannel(CLEANUP_ORPHANED_ATTACHMENTS_KEY);
}

@ -19,6 +19,10 @@ class LokiAppDotNetAPI extends EventEmitter {
this.myPrivateKey = false;
}
async close() {
await Promise.all(this.servers.map(server => server.close()));
}
async getPrivateKey() {
if (!this.myPrivateKey) {
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
@ -89,6 +93,13 @@ class LokiAppDotNetServerAPI {
})();
}
async close() {
this.channels.forEach(channel => channel.stop());
if (this.tokenPromise) {
await this.tokenPromise;
}
}
// channel getter/factory
findOrCreateChannel(channelId, conversationId) {
let thisChannel = this.channels.find(

@ -1,3 +1,6 @@
/* global storage: false */
/* global Signal: false */
const LokiAppDotNetAPI = require('./loki_app_dot_net_api');
const DEVICE_MAPPING_ANNOTATION_KEY = 'network.loki.messenger.devicemapping';
@ -16,17 +19,33 @@ class LokiFileServerAPI {
async getUserDeviceMapping(pubKey) {
const annotations = await this._server.getUserAnnotations(pubKey);
return annotations.find(
const deviceMapping = annotations.find(
annotation => annotation.type === DEVICE_MAPPING_ANNOTATION_KEY
);
return deviceMapping ? deviceMapping.value : null;
}
async updateOurDeviceMapping() {
const isPrimary = !storage.get('isSecondaryDevice');
let authorisations;
if (isPrimary) {
authorisations = await Signal.Data.getGrantAuthorisationsForPrimaryPubKey(
this.ourKey
);
} else {
authorisations = [
await Signal.Data.getGrantAuthorisationForSecondaryPubKey(this.ourKey),
];
}
return this._setOurDeviceMapping(authorisations, isPrimary);
}
async getDeviceMappingForUsers(pubKeys) {
const users = await this._server.getUsersAnnotations(pubKeys);
return users
return users;
}
setOurDeviceMapping(authorisations, isPrimary) {
_setOurDeviceMapping(authorisations, isPrimary) {
const content = {
isPrimary: isPrimary ? '1' : '0',
authorisations,

@ -39,6 +39,9 @@ const calcNonce = (messageEventData, pubKey, data64, timestamp, ttl) => {
};
const trySendP2p = async (pubKey, data64, isPing, messageEventData) => {
if (typeof lokiP2pAPI === 'undefined') {
return false;
}
const p2pDetails = lokiP2pAPI.getContactP2pDetails(pubKey);
if (!p2pDetails || (!isPing && !p2pDetails.isOnline)) {
return false;

@ -21,6 +21,9 @@
storage.get('chromiumRegistrationDone') === ''
);
},
ongoingSecondaryDeviceRegistration() {
return storage.get('secondaryDeviceStatus') === 'ongoing';
},
remove() {
storage.remove('chromiumRegistrationDone');
},

@ -8,6 +8,7 @@
const ROTATION_INTERVAL = 48 * 60 * 60 * 1000;
let timeout;
let scheduledTime;
let shouldStop = false;
function scheduleNextRotation() {
const now = Date.now();
@ -16,6 +17,9 @@
}
function run() {
if (shouldStop) {
return;
}
window.log.info('Rotating signed prekey...');
getAccountManager()
.rotateSignedPreKey()
@ -64,7 +68,11 @@
clearTimeout(timeout);
timeout = setTimeout(runWhenOnline, waitTime);
}
function onTimeTravel() {
if (Whisper.Registration.isDone()) {
setTimeoutForNextRun();
}
}
let initComplete;
Whisper.RotateSignedPreKeyListener = {
init(events, newVersion) {
@ -73,6 +81,7 @@
return;
}
initComplete = true;
shouldStop = false;
if (newVersion) {
runWhenOnline();
@ -80,11 +89,13 @@
setTimeoutForNextRun();
}
events.on('timetravel', () => {
if (Whisper.Registration.isDone()) {
setTimeoutForNextRun();
}
});
events.on('timetravel', onTimeTravel);
},
stop(events) {
initComplete = false;
shouldStop = true;
events.off('timetravel', onTimeTravel);
clearTimeout(timeout);
},
};
})();

@ -39,6 +39,21 @@
return false;
}
function convertVerifiedStatusToProtoState(status) {
switch (status) {
case VerifiedStatus.VERIFIED:
return textsecure.protobuf.Verified.State.VERIFIED;
case VerifiedStatus.UNVERIFIED:
return textsecure.protobuf.Verified.State.VERIFIED;
case VerifiedStatus.DEFAULT:
// intentional fallthrough
default:
return textsecure.protobuf.Verified.State.DEFAULT;
}
}
const StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__;
const StaticArrayBufferProto = new ArrayBuffer().__proto__;
const StaticUint8ArrayProto = new Uint8Array().__proto__;
@ -913,4 +928,5 @@
window.SignalProtocolStore = SignalProtocolStore;
window.SignalProtocolStore.prototype.Direction = Direction;
window.SignalProtocolStore.prototype.VerifiedStatus = VerifiedStatus;
window.SignalProtocolStore.prototype.convertVerifiedStatusToProtoState = convertVerifiedStatusToProtoState;
})();

@ -200,5 +200,29 @@
const dialog = new Whisper.QRDialogView({ string });
this.el.append(dialog.el);
},
showDevicePairingDialog() {
const dialog = new Whisper.DevicePairingDialogView();
dialog.on('startReceivingRequests', () => {
Whisper.events.on('devicePairingRequestReceived', pubKey =>
dialog.requestReceived(pubKey)
);
});
dialog.on('stopReceivingRequests', () => {
Whisper.events.off('devicePairingRequestReceived');
});
dialog.once('devicePairingRequestAccepted', (pubKey, cb) =>
Whisper.events.trigger('devicePairingRequestAccepted', pubKey, cb)
);
dialog.on('devicePairingRequestRejected', pubKey =>
Whisper.events.trigger('devicePairingRequestRejected', pubKey)
);
dialog.once('close', () => {
Whisper.events.off('devicePairingRequestReceived');
});
this.el.append(dialog.el);
},
});
})();

@ -0,0 +1,136 @@
/* global Whisper, i18n, libloki, textsecure */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.DevicePairingDialogView = Whisper.View.extend({
className: 'loki-dialog device-pairing-dialog modal',
templateName: 'device-pairing-dialog',
initialize() {
this.pubKeyRequests = [];
this.reset();
this.render();
this.showView();
},
reset() {
this.pubKey = null;
this.accepted = false;
this.isListening = false;
},
events: {
'click #startPairing': 'startReceivingRequests',
'click #close': 'close',
'click .waitingForRequestView .cancel': 'stopReceivingRequests',
'click .requestReceivedView .skip': 'skipDevice',
'click #allowPairing': 'allowDevice',
'click .requestAcceptedView .ok': 'stopReceivingRequests',
},
render_attributes() {
return {
defaultTitle: i18n('pairedDevices'),
waitingForRequestTitle: i18n('waitingForDeviceToRegister'),
requestReceivedTitle: i18n('devicePairingReceived'),
requestAcceptedTitle: i18n('devicePairingAccepted'),
startPairingText: i18n('pairNewDevice'),
cancelText: i18n('cancel'),
closeText: i18n('close'),
skipText: i18n('skip'),
okText: i18n('ok'),
allowPairingText: i18n('allowPairing'),
};
},
startReceivingRequests() {
this.trigger('startReceivingRequests');
this.isListening = true;
this.showView();
},
stopReceivingRequests() {
this.trigger('stopReceivingRequests');
this.reset();
this.showView();
},
requestReceived(secondaryDevicePubKey) {
// FIFO: push at the front of the array with unshift()
this.pubKeyRequests.unshift(secondaryDevicePubKey);
if (!this.pubKey) {
this.nextPubKey();
this.showView('requestReceived');
}
},
allowDevice() {
this.accepted = true;
this.trigger('devicePairingRequestAccepted', this.pubKey, errors =>
this.transmisssionCB(errors)
);
this.showView();
},
transmisssionCB(errors) {
if (!errors) {
this.$('.transmissionStatus').text(i18n('sent'));
} else {
this.$('.transmissionStatus').text(errors);
}
this.$('.requestAcceptedView .ok').show();
},
skipDevice() {
this.trigger('devicePairingRequestRejected', this.pubKey);
this.nextPubKey();
this.showView();
},
nextPubKey() {
// FIFO: pop at the back of the array using pop()
this.pubKey = this.pubKeyRequests.pop();
},
async showView() {
const defaultView = this.$('.defaultView');
const waitingForRequestView = this.$('.waitingForRequestView');
const requestReceivedView = this.$('.requestReceivedView');
const requestAcceptedView = this.$('.requestAcceptedView');
if (!this.isListening) {
const ourPubKey = textsecure.storage.user.getNumber();
defaultView.show();
requestReceivedView.hide();
waitingForRequestView.hide();
requestAcceptedView.hide();
const pubKeys = await libloki.storage.getSecondaryDevicesFor(ourPubKey);
if (pubKeys && pubKeys.length > 0) {
this.$('#pairedPubKeys').empty();
pubKeys.forEach(x => {
this.$('#pairedPubKeys').append(`<li>${x}</li>`);
});
}
} else if (this.accepted) {
defaultView.hide();
requestReceivedView.hide();
waitingForRequestView.hide();
requestAcceptedView.show();
} else if (this.pubKey) {
const secretWords = window.mnemonic
.mn_encode(this.pubKey.slice(2), 'english')
.split(' ')
.slice(-3)
.join(' ');
this.$('.secretWords').text(secretWords);
requestReceivedView.show();
waitingForRequestView.hide();
requestAcceptedView.hide();
defaultView.hide();
} else {
waitingForRequestView.show();
requestReceivedView.hide();
requestAcceptedView.hide();
defaultView.hide();
}
},
close() {
this.remove();
if (this.pubKey && !this.accepted) {
this.trigger('devicePairingRequestRejected', this.pubKey);
}
this.trigger('close');
},
});
})();

@ -156,6 +156,7 @@
user: {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
isSecondaryDevice: !!window.storage.get('isSecondaryDevice'),
i18n: window.i18n,
},
};

@ -1,4 +1,11 @@
/* global Whisper, $, getAccountManager, textsecure, i18n, passwordUtil, _ */
/* global Whisper,
$,
getAccountManager,
textsecure,
i18n,
passwordUtil,
_,
*/
/* eslint-disable more/no-then */
@ -13,6 +20,8 @@
className: 'full-screen-flow standalone-fullscreen',
initialize() {
this.accountManager = getAccountManager();
// Clean status in case the app closed unexpectedly
textsecure.storage.remove('secondaryDeviceStatus');
this.render();
@ -26,6 +35,7 @@
this.$('#error').hide();
this.$('.standalone-mnemonic').hide();
this.$('.standalone-secondary-device').hide();
this.onGenerateMnemonic();
@ -49,9 +59,14 @@
this.registrationParams = {};
this.$pages = this.$('.page');
this.pairingInterval = null;
this.showRegisterPage();
this.onValidatePassword();
this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind(
this
);
},
events: {
'validation input.number': 'onValidation',
@ -60,6 +75,8 @@
'change #code': 'onChangeCode',
'click #register': 'registerWithoutMnemonic',
'click #register-mnemonic': 'registerWithMnemonic',
'click #register-secondary-device': 'registerSecondaryDevice',
'click #cancel-secondary-device': 'cancelSecondaryDevice',
'click #back-button': 'onBack',
'click #save-button': 'onSaveProfile',
'change #mnemonic': 'onChangeMnemonic',
@ -103,7 +120,12 @@
const input = this.trim(this.$passwordInput.val());
// Ensure we clear the secondary device registration status
textsecure.storage.remove('secondaryDeviceStatus');
try {
await this.resetRegistration();
await window.setPassword(input);
await this.accountManager.registerSingleDevice(
mnemonic,
@ -123,6 +145,101 @@
const language = this.$('#mnemonic-display-language').val();
this.showProfilePage(mnemonic, language);
},
async onSecondaryDeviceRegistered() {
clearInterval(this.pairingInterval);
// Ensure the left menu is updated
Whisper.events.trigger('userChanged', { isSecondaryDevice: true });
// will re-run the background initialisation
Whisper.events.trigger('registration_done');
this.$el.trigger('openInbox');
},
async resetRegistration() {
await window.Signal.Data.removeAllIdentityKeys();
await window.Signal.Data.removeAllPrivateConversations();
Whisper.Registration.remove();
// Do not remove all items since they are only set
// at startup.
textsecure.storage.remove('identityKey');
textsecure.storage.remove('secondaryDeviceStatus');
window.ConversationController.reset();
await window.ConversationController.load();
Whisper.RotateSignedPreKeyListener.stop(Whisper.events);
},
async cancelSecondaryDevice() {
Whisper.events.off(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
this.$('#register-secondary-device')
.removeAttr('disabled')
.text('Link');
this.$('#cancel-secondary-device').hide();
this.$('.standalone-secondary-device #pubkey').text('');
await this.resetRegistration();
},
async registerSecondaryDevice() {
if (textsecure.storage.get('secondaryDeviceStatus') === 'ongoing') {
return;
}
await this.resetRegistration();
textsecure.storage.put('secondaryDeviceStatus', 'ongoing');
this.$('#register-secondary-device')
.attr('disabled', 'disabled')
.text('Sending...');
this.$('#cancel-secondary-device').show();
const mnemonic = this.$('#mnemonic-display').text();
const language = this.$('#mnemonic-display-language').val();
const primaryPubKey = this.$('#primary-pubkey').val();
this.$('.standalone-secondary-device #error').hide();
// Ensure only one listener
Whisper.events.off(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
Whisper.events.once(
'secondaryDeviceRegistration',
this.onSecondaryDeviceRegistered
);
const onError = async error => {
this.$('.standalone-secondary-device #error')
.text(error)
.show();
await this.resetRegistration();
this.$('#register-secondary-device')
.removeAttr('disabled')
.text('Link');
this.$('#cancel-secondary-device').hide();
};
const c = new Whisper.Conversation({
id: primaryPubKey,
type: 'private',
});
const validationError = c.validateNumber();
if (validationError) {
onError('Invalid public key');
return;
}
try {
await this.accountManager.registerSingleDevice(
mnemonic,
language,
null
);
await this.accountManager.requestPairing(primaryPubKey);
const pubkey = textsecure.storage.user.getNumber();
const words = window.mnemonic
.mn_encode(pubkey.slice(2), 'english')
.split(' ')
.slice(-3)
.join(' ');
this.$('.standalone-secondary-device #pubkey').text(
`Here is your secret:\n${words}`
);
} catch (e) {
onError(e);
}
},
registerWithMnemonic() {
const mnemonic = this.$('#mnemonic').val();
const language = this.$('#mnemonic-language').val();

@ -1,4 +1,4 @@
/* global window, textsecure, log */
/* global window, textsecure, log, Whisper, dcodeIO, StringView */
// eslint-disable-next-line func-names
(function() {
@ -65,9 +65,155 @@
await outgoingMessage.sendToNumber(pubKey);
}
function createPairingAuthorisationProtoMessage({
primaryDevicePubKey,
secondaryDevicePubKey,
requestSignature,
grantSignature,
type,
}) {
if (
!primaryDevicePubKey ||
!secondaryDevicePubKey ||
!requestSignature ||
typeof type !== 'number'
) {
throw new Error(
'createPairingAuthorisationProtoMessage: pubkeys or type is not set'
);
}
if (requestSignature.constructor !== ArrayBuffer) {
throw new Error(
'createPairingAuthorisationProtoMessage expects a signature as ArrayBuffer'
);
}
if (grantSignature && grantSignature.constructor !== ArrayBuffer) {
throw new Error(
'createPairingAuthorisationProtoMessage expects a signature as ArrayBuffer'
);
}
return new textsecure.protobuf.PairingAuthorisationMessage({
requestSignature: new Uint8Array(requestSignature),
grantSignature: grantSignature ? new Uint8Array(grantSignature) : null,
primaryDevicePubKey,
secondaryDevicePubKey,
type,
});
}
// Serialise as <Element0.length><Element0><Element1.length><Element1>...
// This is an implementation of the reciprocal of contacts_parser.js
function serialiseByteBuffers(buffers) {
const result = new dcodeIO.ByteBuffer();
buffers.forEach(buffer => {
// bytebuffer container expands and increments
// offset automatically
result.writeVarint32(buffer.limit);
result.append(buffer);
});
result.limit = result.offset;
result.reset();
return result;
}
async function createContactSyncProtoMessage() {
const conversations = await window.Signal.Data.getConversationsWithFriendStatus(
window.friends.friendRequestStatusEnum.friends,
{ ConversationCollection: Whisper.ConversationCollection }
);
// Extract required contacts information out of conversations
const rawContacts = conversations.map(conversation => {
const profile = conversation.getLokiProfile();
const number = conversation.getNumber();
const name = profile
? profile.displayName
: conversation.getProfileName();
const status = conversation.safeGetVerified();
const protoState = textsecure.storage.protocol.convertVerifiedStatusToProtoState(
status
);
const verified = new textsecure.protobuf.Verified({
state: protoState,
destination: number,
identityKey: StringView.hexToArrayBuffer(number),
});
return {
name,
verified,
number,
nickname: conversation.getNickname(),
blocked: conversation.isBlocked(),
expireTimer: conversation.get('expireTimer'),
};
});
// Convert raw contacts to an array of buffers
const contactDetails = rawContacts
.filter(x => x.number !== textsecure.storage.user.getNumber())
.map(x => new textsecure.protobuf.ContactDetails(x))
.map(x => x.encode());
// Serialise array of byteBuffers into 1 byteBuffer
const byteBuffer = serialiseByteBuffers(contactDetails);
const data = new Uint8Array(byteBuffer.toArrayBuffer());
const contacts = new textsecure.protobuf.SyncMessage.Contacts({
data,
});
const syncMessage = new textsecure.protobuf.SyncMessage({
contacts,
});
return syncMessage;
}
async function sendPairingAuthorisation(authorisation, recipientPubKey) {
const pairingAuthorisation = createPairingAuthorisationProtoMessage(
authorisation
);
// Send profile name to secondary device
const ourNumber = textsecure.storage.user.getNumber();
const conversation = await window.ConversationController.getOrCreateAndWait(
ourNumber,
'private'
);
const lokiProfile = conversation.getLokiProfile();
const profile = new textsecure.protobuf.DataMessage.LokiProfile(
lokiProfile
);
const dataMessage = new textsecure.protobuf.DataMessage({
profile,
});
// Attach contact list
const syncMessage = await createContactSyncProtoMessage();
const content = new textsecure.protobuf.Content({
pairingAuthorisation,
dataMessage,
syncMessage,
});
// Send
const options = { messageType: 'pairing-request' };
const p = new Promise((resolve, reject) => {
const outgoingMessage = new textsecure.OutgoingMessage(
null, // server
Date.now(), // timestamp,
[recipientPubKey], // numbers
content, // message
true, // silent
result => {
// callback
if (result.errors.length > 0) {
reject(result.errors[0]);
} else {
resolve();
}
},
options
);
outgoingMessage.sendToNumber(recipientPubKey);
});
return p;
}
window.libloki.api = {
sendBackgroundMessage,
sendOnlineBroadcastMessage,
broadcastOnlineStatus,
sendPairingAuthorisation,
createPairingAuthorisationProtoMessage,
createContactSyncProtoMessage,
};
})();

@ -158,6 +158,125 @@
}
}
async function generateSignatureForPairing(secondaryPubKey, type) {
const pubKeyArrayBuffer = StringView.hexToArrayBuffer(secondaryPubKey);
// Make sure the signature includes the pairing action (pairing or unpairing)
const len = pubKeyArrayBuffer.byteLength;
const data = new Uint8Array(len + 1);
data.set(new Uint8Array(pubKeyArrayBuffer), 0);
data[len] = type;
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
const signature = await libsignal.Curve.async.calculateSignature(
myKeyPair.privKey,
data.buffer
);
return signature;
}
async function validateAuthorisation(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(
'Received a pairing request with missing pubkeys. Ignored.'
);
return false;
} else if (!requestSignature) {
window.log.warn(
'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;
await this.verifyPairingSignature(
primaryDevicePubKey,
secondaryDevicePubKey,
dcodeIO.ByteBuffer.wrap(signature, encoding).toArrayBuffer(),
signatureType
);
};
try {
await verify(
requestSignature,
textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST
);
} catch (e) {
window.log.warn(
'Could not verify pairing request authorisation signature. Ignoring message.'
);
window.log.error(e);
return false;
}
if (isGrant) {
try {
await verify(
grantSignature,
textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT
);
} catch (e) {
window.log.warn(
'Could not verify pairing grant authorisation signature. Ignoring message.'
);
window.log.error(e);
return false;
}
}
return true;
}
async function verifyPairingSignature(
primaryDevicePubKey,
secondaryPubKey,
signature,
type
) {
const secondaryPubKeyArrayBuffer = StringView.hexToArrayBuffer(
secondaryPubKey
);
const primaryDevicePubKeyArrayBuffer = StringView.hexToArrayBuffer(
primaryDevicePubKey
);
const len = secondaryPubKeyArrayBuffer.byteLength;
const data = new Uint8Array(len + 1);
// For REQUEST type message, the secondary device signs the primary device pubkey
// For GRANT type message, the primary device signs the secondary device pubkey
let issuer;
if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT) {
data.set(new Uint8Array(secondaryPubKeyArrayBuffer));
issuer = primaryDevicePubKeyArrayBuffer;
} else if (
type === textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST
) {
data.set(new Uint8Array(primaryDevicePubKeyArrayBuffer));
issuer = secondaryPubKeyArrayBuffer;
}
data[len] = type;
// Throws for invalid signature
await libsignal.Curve.async.verifySignature(issuer, data.buffer, signature);
}
async function decryptToken({ cipherText64, serverPubKey64 }) {
const ivAndCiphertext = new Uint8Array(
dcodeIO.ByteBuffer.fromBase64(cipherText64).toArrayBuffer()
@ -177,7 +296,6 @@
const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8');
return tokenString;
}
const snodeCipher = new LokiSnodeChannel();
window.libloki.crypto = {
@ -187,6 +305,9 @@
FallBackDecryptionError,
snodeCipher,
decryptToken,
generateSignatureForPairing,
verifyPairingSignature,
validateAuthorisation,
// for testing
_LokiSnodeChannel: LokiSnodeChannel,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,

@ -1,4 +1,4 @@
/* global window, libsignal, textsecure */
/* global window, libsignal, textsecure, Signal, lokiFileServerAPI */
// eslint-disable-next-line func-names
(function() {
@ -113,11 +113,112 @@
}
}
// fetches device mappings from server.
async function getPrimaryDeviceMapping(pubKey) {
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(pubKey);
if (!deviceMapping) {
return [];
}
let { authorisations } = deviceMapping;
if (!authorisations) {
return [];
}
if (deviceMapping.isPrimary !== '1') {
const { primaryDevicePubKey } = authorisations.find(
authorisation => authorisation.secondaryDevicePubKey === pubKey
);
if (primaryDevicePubKey) {
// do NOT call getprimaryDeviceMapping recursively
// in case both devices are out of sync and think they are
// each others' secondary pubkey.
({ authorisations } = await lokiFileServerAPI.getUserDeviceMapping(
primaryDevicePubKey
));
}
}
return authorisations || [];
}
// if the device is a secondary device,
// fetch the device mappings for its primary device
async function saveAllPairingAuthorisationsFor(pubKey) {
const authorisations = await getPrimaryDeviceMapping(pubKey);
await Promise.all(
authorisations.map(authorisation =>
savePairingAuthorisation(authorisation)
)
);
}
function savePairingAuthorisation(authorisation) {
return window.Signal.Data.createOrUpdatePairingAuthorisation(authorisation);
}
function removePairingAuthorisationForSecondaryPubKey(pubKey) {
return window.Signal.Data.removePairingAuthorisationForSecondaryPubKey(
pubKey
);
}
// Transforms signatures from base64 to ArrayBuffer!
async function getGrantAuthorisationForSecondaryPubKey(secondaryPubKey) {
const authorisation = await window.Signal.Data.getGrantAuthorisationForSecondaryPubKey(
secondaryPubKey
);
if (!authorisation) {
return null;
}
return {
...authorisation,
requestSignature: Signal.Crypto.base64ToArrayBuffer(
authorisation.requestSignature
),
grantSignature: Signal.Crypto.base64ToArrayBuffer(
authorisation.grantSignature
),
};
}
// Transforms signatures from base64 to ArrayBuffer!
async function getAuthorisationForSecondaryPubKey(secondaryPubKey) {
const authorisation = await window.Signal.Data.getAuthorisationForSecondaryPubKey(
secondaryPubKey
);
if (!authorisation) {
return null;
}
return {
...authorisation,
requestSignature: Signal.Crypto.base64ToArrayBuffer(
authorisation.requestSignature
),
grantSignature: authorisation.grantSignature
? Signal.Crypto.base64ToArrayBuffer(authorisation.grantSignature)
: null,
};
}
function getSecondaryDevicesFor(primaryDevicePubKey) {
return window.Signal.Data.getSecondaryDevicesFor(primaryDevicePubKey);
}
async function getAllDevicePubKeysForPrimaryPubKey(primaryDevicePubKey) {
const secondaryPubKeys =
(await getSecondaryDevicesFor(primaryDevicePubKey)) || [];
return secondaryPubKeys.concat(primaryDevicePubKey);
}
window.libloki.storage = {
getPreKeyBundleForContact,
saveContactPreKeyBundle,
removeContactPreKeyBundle,
verifyFriendRequestAcceptPreKey,
savePairingAuthorisation,
saveAllPairingAuthorisationsFor,
removePairingAuthorisationForSecondaryPubKey,
getGrantAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getAllDevicePubKeysForPrimaryPubKey,
getSecondaryDevicesFor,
};
// Libloki protocol store

@ -2,6 +2,8 @@
window,
textsecure,
libsignal,
libloki,
lokiFileServerAPI,
mnemonic,
btoa,
Signal,
@ -11,7 +13,8 @@
StringView,
log,
Event,
ConversationController
ConversationController,
Whisper
*/
/* eslint-disable more/no-then */
@ -552,6 +555,90 @@
this.dispatchEvent(new Event('registration'));
},
async requestPairing(primaryDevicePubKey) {
// throws if invalid
this.validatePubKeyHex(primaryDevicePubKey);
// we need a conversation for sending a message
await ConversationController.getOrCreateAndWait(
primaryDevicePubKey,
'private'
);
const ourPubKey = textsecure.storage.user.getNumber();
if (primaryDevicePubKey === ourPubKey) {
throw new Error('Cannot request to pair with ourselves');
}
const requestType =
textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST;
const requestSignature = await libloki.crypto.generateSignatureForPairing(
primaryDevicePubKey,
requestType
);
const authorisation = {
primaryDevicePubKey,
secondaryDevicePubKey: ourPubKey,
requestSignature,
type: requestType,
};
await libloki.api.sendPairingAuthorisation(
authorisation,
primaryDevicePubKey
);
},
async authoriseSecondaryDevice(secondaryDevicePubKey) {
const ourPubKey = textsecure.storage.user.getNumber();
if (secondaryDevicePubKey === ourPubKey) {
throw new Error(
'Cannot register primary device pubkey as secondary device'
);
}
// throws if invalid
this.validatePubKeyHex(secondaryDevicePubKey);
// we need a conversation for sending a message
await ConversationController.getOrCreateAndWait(
secondaryDevicePubKey,
'private'
);
const grantType =
textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT;
const grantSignature = await libloki.crypto.generateSignatureForPairing(
secondaryDevicePubKey,
grantType
);
const existingAuthorisation = await libloki.storage.getAuthorisationForSecondaryPubKey(
secondaryDevicePubKey
);
if (!existingAuthorisation) {
throw new Error(
'authoriseSecondaryDevice: request signature missing from database!'
);
}
const { requestSignature } = existingAuthorisation;
const authorisation = {
primaryDevicePubKey: ourPubKey,
secondaryDevicePubKey,
requestSignature,
grantSignature,
type: grantType,
};
// Update authorisation in database with the new grant signature
await libloki.storage.savePairingAuthorisation(authorisation);
await lokiFileServerAPI.updateOurDeviceMapping();
await libloki.api.sendPairingAuthorisation(
authorisation,
secondaryDevicePubKey
);
},
validatePubKeyHex(pubKey) {
const c = new Whisper.Conversation({
id: pubKey,
type: 'private',
});
const validationError = c.validateNumber();
if (validationError) {
throw new Error(validationError);
}
},
});
textsecure.AccountManager = AccountManager;
})();

@ -18,6 +18,8 @@
/* global lokiMessageAPI: false */
/* global lokiP2pAPI: false */
/* global feeds: false */
/* global Whisper: false */
/* global lokiFileServerAPI: false */
/* eslint-disable more/no-then */
/* eslint-disable no-unreachable */
@ -76,11 +78,15 @@ MessageReceiver.prototype.extend({
handleRequest: this.handleRequest.bind(this),
});
this.httpPollingResource.pollServer();
localLokiServer.on('message', this.handleP2pMessage.bind(this));
lokiPublicChatAPI.on(
'publicMessage',
this.handleUnencryptedMessage.bind(this)
);
if (localLokiServer) {
localLokiServer.on('message', this.handleP2pMessage.bind(this));
}
if (lokiPublicChatAPI) {
lokiPublicChatAPI.on(
'publicMessage',
this.handleUnencryptedMessage.bind(this)
);
}
// set up pollers for any RSS feeds
feeds.forEach(feed => {
feed.on('rssMessage', this.handleUnencryptedMessage.bind(this));
@ -117,6 +123,9 @@ MessageReceiver.prototype.extend({
this.incoming = [this.pending];
},
async startLocalServer() {
if (!localLokiServer) {
return;
}
try {
// clearnet change: getMyLokiIp -> getMyClearIp
// const myLokiIp = await window.lokiSnodeAPI.getMyLokiIp();
@ -183,7 +192,7 @@ MessageReceiver.prototype.extend({
);
}
},
close() {
async close() {
window.log.info('MessageReceiver.close()');
this.calledClose = true;
@ -197,6 +206,10 @@ MessageReceiver.prototype.extend({
localLokiServer.close();
}
if (lokiPublicChatAPI) {
await lokiPublicChatAPI.close();
}
if (this.httpPollingResource) {
this.httpPollingResource.close();
}
@ -1046,6 +1059,166 @@ MessageReceiver.prototype.extend({
}
return this.removeFromCache(envelope);
},
async handlePairingRequest(envelope, pairingRequest) {
const valid = await libloki.crypto.validateAuthorisation(pairingRequest);
if (valid) {
// Pairing dialog is open and is listening
if (Whisper.events.isListenedTo('devicePairingRequestReceived')) {
await window.libloki.storage.savePairingAuthorisation(pairingRequest);
Whisper.events.trigger(
'devicePairingRequestReceived',
pairingRequest.secondaryDevicePubKey
);
}
// Ignore requests if the dialog is closed
}
return this.removeFromCache(envelope);
},
async handleAuthorisationForSelf(
envelope,
pairingAuthorisation,
{ dataMessage, syncMessage }
) {
const valid = await libloki.crypto.validateAuthorisation(
pairingAuthorisation
);
const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice');
let removedFromCache = false;
if (alreadySecondaryDevice) {
window.log.warn(
'Received an unexpected pairing authorisation (device is already paired as secondary device). Ignoring.'
);
} else if (!valid) {
window.log.warn(
'Received invalid pairing authorisation for self. Could not verify signature. Ignoring.'
);
} else {
const { type, primaryDevicePubKey } = pairingAuthorisation;
if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.GRANT) {
// Authorisation received to become a secondary device
window.log.info(
`Received pairing authorisation from ${primaryDevicePubKey}`
);
await libloki.storage.savePairingAuthorisation(pairingAuthorisation);
// Set current device as secondary.
// This will ensure the authorisation is sent
// along with each friend request.
window.storage.remove('secondaryDeviceStatus');
window.storage.put('isSecondaryDevice', true);
Whisper.events.trigger('secondaryDeviceRegistration');
// Update profile name
if (dataMessage && dataMessage.profile) {
const ourNumber = textsecure.storage.user.getNumber();
const me = window.ConversationController.get(ourNumber);
if (me) {
me.setLokiProfile(dataMessage.profile);
}
}
// Update contact list
if (syncMessage && syncMessage.contacts) {
// This call already removes the envelope from the cache
await this.handleContacts(envelope, syncMessage.contacts);
removedFromCache = true;
await this.sendFriendRequestsToSyncContacts(syncMessage.contacts);
}
} else {
window.log.warn('Unimplemented pairing authorisation message type');
}
}
if (!removedFromCache) {
await this.removeFromCache(envelope);
}
},
async sendFriendRequestsToSyncContacts(contacts) {
const attachmentPointer = await this.handleAttachment(contacts);
const contactBuffer = new ContactBuffer(attachmentPointer.data);
let contactDetails = contactBuffer.next();
// Extract just the pubkeys
const friendPubKeys = [];
while (contactDetails !== undefined) {
friendPubKeys.push(contactDetails.number);
contactDetails = contactBuffer.next();
}
return Promise.all(
friendPubKeys.map(async pubKey => {
const c = await window.ConversationController.getOrCreateAndWait(
pubKey,
'private'
);
if (!c) {
return null;
}
const attachments = [];
const quote = null;
const linkPreview = null;
// Send an empty message, the underlying logic will know
// it should send a friend request
return c.sendMessage('', attachments, quote, linkPreview);
})
);
},
async handleAuthorisationForContact(envelope) {
window.log.error(
'Unexpected pairing request/authorisation received, ignoring.'
);
return this.removeFromCache(envelope);
},
async handlePairingAuthorisationMessage(envelope, content) {
const { pairingAuthorisation } = content;
const { type, secondaryDevicePubKey } = pairingAuthorisation;
if (type === textsecure.protobuf.PairingAuthorisationMessage.Type.REQUEST) {
return this.handlePairingRequest(envelope, pairingAuthorisation);
} else if (secondaryDevicePubKey === textsecure.storage.user.getNumber()) {
return this.handleAuthorisationForSelf(
envelope,
pairingAuthorisation,
content
);
}
return this.handleAuthorisationForContact(envelope);
},
async handleSecondaryDeviceFriendRequest(pubKey, deviceMapping) {
if (!deviceMapping) {
return false;
}
// Only handle secondary pubkeys
if (deviceMapping.isPrimary === '1' || !deviceMapping.authorisations) {
return false;
}
const { authorisations } = deviceMapping;
// Secondary devices should only have 1 authorisation from a primary device
if (authorisations.length !== 1) {
return false;
}
const authorisation = authorisations[0];
if (!authorisation) {
return false;
}
if (!authorisation.grantSignature) {
return false;
}
const isValid = await libloki.crypto.validateAuthorisation(authorisation);
if (!isValid) {
return false;
}
const correctSender = pubKey === authorisation.secondaryDevicePubKey;
if (!correctSender) {
return false;
}
const { primaryDevicePubKey } = authorisation;
// ensure the primary device is a friend
const c = window.ConversationController.get(primaryDevicePubKey);
if (!c || !c.isFriend()) {
return false;
}
await libloki.storage.savePairingAuthorisation(authorisation);
// sending a message back = accepting friend request
window.libloki.api.sendBackgroundMessage(pubKey);
return true;
},
handleDataMessage(envelope, msg) {
if (!envelope.isP2p) {
const timestamp = envelope.timestamp.toNumber();
@ -1085,9 +1258,25 @@ MessageReceiver.prototype.extend({
await conversation.setLokiProfile(profile);
}
if (friendRequest && isMe) {
window.log.info('refusing to add a friend request to ourselves');
throw new Error('Cannot add a friend request for ourselves!');
if (friendRequest) {
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) {
return this.removeFromCache(envelope);
}
}
}
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
@ -1157,6 +1346,9 @@ MessageReceiver.prototype.extend({
content.lokiAddressMessage
);
}
if (content.pairingAuthorisation) {
return this.handlePairingAuthorisationMessage(envelope, content);
}
if (content.syncMessage) {
return this.handleSyncMessage(envelope, content.syncMessage);
}
@ -1328,11 +1520,11 @@ MessageReceiver.prototype.extend({
},
handleContacts(envelope, contacts) {
window.log.info('contact sync');
const { blob } = contacts;
// const { blob } = contacts;
// Note: we do not return here because we don't want to block the next message on
// this attachment download and a lot of processing of that attachment.
this.handleAttachment(blob).then(attachmentPointer => {
this.handleAttachment(contacts).then(attachmentPointer => {
const results = [];
const contactBuffer = new ContactBuffer(attachmentPointer.data);
let contactDetails = contactBuffer.next();
@ -1435,8 +1627,8 @@ MessageReceiver.prototype.extend({
};
},
async downloadAttachment(attachment) {
window.log.info('Not downloading attachments.');
return Promise.reject();
// window.log.info('Not downloading attachments.');
// return Promise.reject();
const encrypted = await this.server.getAttachment(attachment.id);
const { key, digest, size } = attachment;
@ -1461,8 +1653,11 @@ MessageReceiver.prototype.extend({
};
},
handleAttachment(attachment) {
window.log.info('Not handling attachments.');
return Promise.reject();
// window.log.info('Not handling attachments.');
return Promise.resolve({
...attachment,
data: dcodeIO.ByteBuffer.wrap(attachment.data).toArrayBuffer(), // ByteBuffer to ArrayBuffer
});
const cleaned = this.cleanAttachment(attachment);
return this.downloadAttachment(cleaned);

@ -96,18 +96,20 @@ OutgoingMessage.prototype = {
},
reloadDevicesAndSend(number, recurse) {
return () =>
textsecure.storage.protocol.getDeviceIds(number).then(deviceIds => {
if (deviceIds.length === 0) {
// eslint-disable-next-line no-param-reassign
deviceIds = [1];
// return this.registerError(
// number,
// 'Got empty device list when loading device keys',
// null
// );
}
return this.doSendMessage(number, deviceIds, recurse);
});
libloki.storage
.getAllDevicePubKeysForPrimaryPubKey(number)
.then(devicesPubKeys => {
if (devicesPubKeys.length === 0) {
// eslint-disable-next-line no-param-reassign
devicesPubKeys = [number];
// return this.registerError(
// number,
// 'Got empty device list when loading device keys',
// null
// );
}
return this.doSendMessage(number, devicesPubKeys, recurse);
});
},
getKeysForNumber(number, updateDevices) {
@ -271,7 +273,7 @@ OutgoingMessage.prototype = {
const bytes = new Uint8Array(websocketMessage.encode().toArrayBuffer());
return bytes;
},
doSendMessage(number, deviceIds, recurse) {
doSendMessage(number, devicesPubKeys, recurse) {
const ciphers = {};
if (this.isPublic) {
return this.transmitMessage(
@ -289,6 +291,8 @@ OutgoingMessage.prototype = {
});
}
this.numbers = devicesPubKeys;
/* Disabled because i'm not sure how senderCertificate works :thinking:
const { numberInfo, senderCertificate } = this;
const info = numberInfo && numberInfo[number] ? numberInfo[number] : {};
@ -320,8 +324,14 @@ OutgoingMessage.prototype = {
*/
return Promise.all(
deviceIds.map(async deviceId => {
const address = new libsignal.SignalProtocolAddress(number, deviceId);
devicesPubKeys.map(async devicePubKey => {
// Loki Messenger 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(
devicePubKey,
deviceId
);
const ourKey = textsecure.storage.user.getNumber();
const options = {};
const fallBackCipher = new libloki.crypto.FallBackSessionCipher(
@ -331,12 +341,13 @@ OutgoingMessage.prototype = {
// Check if we need to attach the preKeys
let sessionCipher;
const isFriendRequest = this.messageType === 'friend-request';
this.fallBackEncryption = this.fallBackEncryption || isFriendRequest;
const flags = this.message.dataMessage
? this.message.dataMessage.get_flags()
: null;
const isEndSession =
flags === textsecure.protobuf.DataMessage.Flags.END_SESSION;
if (isFriendRequest || isEndSession) {
if (this.fallBackEncryption || isEndSession) {
// Encrypt them with the fallback
const pkb = await libloki.storage.getPreKeyBundleForContact(number);
const preKeyBundleMessage = new textsecure.protobuf.PreKeyBundleMessage(
@ -345,7 +356,7 @@ OutgoingMessage.prototype = {
this.message.preKeyBundleMessage = preKeyBundleMessage;
window.log.info('attaching prekeys to outgoing message');
}
if (isFriendRequest) {
if (this.fallBackEncryption) {
sessionCipher = fallBackCipher;
} else {
sessionCipher = new libsignal.SessionCipher(
@ -371,17 +382,21 @@ OutgoingMessage.prototype = {
dcodeIO.ByteBuffer.wrap(ciphertext.body, 'binary').toArrayBuffer()
);
}
let ttl;
if (this.messageType === 'friend-request') {
ttl = 4 * 24 * 60 * 60 * 1000; // 4 days for friend request message
} else if (this.messageType === 'onlineBroadcast') {
ttl = 60 * 1000; // 1 minute for online broadcast message
} else if (this.messageType === 'typing') {
ttl = 60 * 1000; // 1 minute for typing indicators
} else {
const hours = window.getMessageTTL() || 24; // 1 day default for any other message
ttl = hours * 60 * 60 * 1000;
}
const getTTL = type => {
switch (type) {
case 'friend-request':
return 4 * 24 * 60 * 60 * 1000; // 4 days for friend request message
case 'onlineBroadcast':
return 60 * 1000; // 1 minute for online broadcast message
case 'typing':
return 60 * 1000; // 1 minute for typing indicators
case 'pairing-request':
return 2 * 60 * 1000; // 2 minutes for pairing requests
default:
return (window.getMessageTTL() || 24) * 60 * 60 * 1000; // 1 day default for any other message
}
};
const ttl = getTTL(this.messageType);
return {
type: ciphertext.type, // FallBackSessionCipher sets this to FRIEND_REQUEST
@ -390,20 +405,41 @@ OutgoingMessage.prototype = {
sourceDevice: 1,
destinationRegistrationId: ciphertext.registrationId,
content: ciphertext.body,
pubKey: devicePubKey,
};
})
)
.then(async outgoingObjects => {
// TODO: handle multiple devices/messages per transmit
const outgoingObject = outgoingObjects[0];
const socketMessage = await this.wrapInWebsocketMessage(outgoingObject);
await this.transmitMessage(
number,
socketMessage,
this.timestamp,
outgoingObject.ttl
);
this.successfulNumbers[this.successfulNumbers.length] = number;
const promises = outgoingObjects.map(async outgoingObject => {
const destination = outgoingObject.pubKey;
try {
const socketMessage = await this.wrapInWebsocketMessage(
outgoingObject
);
await this.transmitMessage(
destination,
socketMessage,
this.timestamp,
outgoingObject.ttl
);
this.successfulNumbers.push(destination);
} catch (e) {
e.number = destination;
this.errors.push(e);
}
});
await Promise.all(promises);
// TODO: the retrySend should only send to the devices
// for which the transmission failed.
// ensure numberCompleted() will execute the callback
this.numbersCompleted +=
this.errors.length + this.successfulNumbers.length;
// Absorb errors if message sent to at least 1 device
if (this.successfulNumbers.length > 0) {
this.errors = [];
}
this.numberCompleted();
})
.catch(error => {
@ -459,7 +495,7 @@ OutgoingMessage.prototype = {
window.log.error(
'Got "key changed" error from encrypt - no identityKey for application layer',
number,
deviceIds
devicesPubKeys
);
throw error;
} else {

@ -36,6 +36,7 @@ message Content {
optional TypingMessage typingMessage = 6;
optional PreKeyBundleMessage preKeyBundleMessage = 101;
optional LokiAddressMessage lokiAddressMessage = 102;
optional PairingAuthorisationMessage pairingAuthorisation = 103;
}
message LokiAddressMessage {
@ -48,6 +49,19 @@ message LokiAddressMessage {
optional Type type = 3;
}
message PairingAuthorisationMessage {
enum Type {
REQUEST = 1;
GRANT = 2;
REVOKE = 3;
}
optional string primaryDevicePubKey = 1;
optional string secondaryDevicePubKey = 2;
optional bytes requestSignature = 3;
optional bytes grantSignature = 4;
optional Type type = 5;
}
message PreKeyBundleMessage {
optional bytes identityKey = 1;
optional uint32 deviceId = 2;
@ -258,6 +272,7 @@ message SyncMessage {
message Contacts {
optional AttachmentPointer blob = 1;
optional bool complete = 2 [default = false];
optional bytes data = 101;
}
message Groups {
@ -351,6 +366,7 @@ message ContactDetails {
optional bytes profileKey = 6;
optional bool blocked = 7;
optional uint32 expireTimer = 8;
optional string nickname = 101;
}
message GroupDetails {

@ -714,6 +714,12 @@ $loading-height: 16px;
.button {
background: $color-loki-green-gradient;
border-radius: 100px;
&:disabled,
&:disabled:hover {
background: $color-loki-dark-gray;
cursor: default;
}
}
#mnemonic-display {

@ -13,6 +13,7 @@ import { ContactName } from './conversation/ContactName';
import { cleanSearchTerm } from '../util/cleanSearchTerm';
import { LocalizerType } from '../types/Util';
import { SearchOptions } from '../types/Search';
import { clipboard } from 'electron';
import { validateNumber } from '../types/PhoneNumber';
@ -37,17 +38,11 @@ export interface Props {
verified: boolean;
profileName?: string;
avatarPath?: string;
isSecondaryDevice: boolean;
i18n: LocalizerType;
updateSearchTerm: (searchTerm: string) => void;
search: (
query: string,
options: {
regionCode: string;
ourNumber: string;
noteToSelf: string;
}
) => void;
search: (query: string, options: SearchOptions) => void;
clearSearch: () => void;
onClick?: () => void;
@ -88,7 +83,9 @@ export class MainHeader extends React.Component<Props, any> {
setInterval(() => {
const clipboardText = clipboard.readText();
this.setState({ clipboardText });
if (this.state.clipboardText !== clipboardText) {
this.setState({ clipboardText });
}
}, 100);
}
@ -98,18 +95,29 @@ export class MainHeader extends React.Component<Props, any> {
}
public componentDidUpdate(_prevProps: Props, prevState: any) {
if (prevState.hasPass !== this.state.hasPass) {
if (
prevState.hasPass !== this.state.hasPass ||
_prevProps.isSecondaryDevice !== this.props.isSecondaryDevice
) {
this.updateMenuItems();
}
}
public search() {
const { searchTerm, search, i18n, ourNumber, regionCode } = this.props;
const {
searchTerm,
search,
i18n,
ourNumber,
regionCode,
isSecondaryDevice,
} = this.props;
if (search) {
search(searchTerm, {
noteToSelf: i18n('noteToSelf').toLowerCase(),
ourNumber,
regionCode,
isSecondaryDevice,
});
}
}
@ -304,7 +312,7 @@ export class MainHeader extends React.Component<Props, any> {
}
private updateMenuItems() {
const { i18n, onCopyPublicKey } = this.props;
const { i18n, onCopyPublicKey, isSecondaryDevice } = this.props;
const { hasPass } = this.state;
const menuItems = [
@ -358,6 +366,16 @@ export class MainHeader extends React.Component<Props, any> {
menuItems.push(passItem('set'));
}
if (!isSecondaryDevice) {
menuItems.push({
id: 'pairNewDevice',
name: 'Device Pairing',
onClick: () => {
trigger('showDevicePairingDialog');
},
});
}
this.setState({ menuItems });
}
}

@ -1,10 +1,12 @@
import { omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
import { SearchOptions } from '../../types/Search';
import { trigger } from '../../shims/events';
// import { getMessageModel } from '../../shims/Whisper';
// import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import {
getPrimaryDeviceFor,
searchConversations /*, searchMessages */,
} from '../../../js/modules/data';
import { makeLookup } from '../../util/makeLookup';
@ -81,7 +83,7 @@ export const actions = {
function search(
query: string,
options: { regionCode: string; ourNumber: string; noteToSelf: string }
options: SearchOptions
): SearchResultsKickoffActionType {
return {
type: 'SEARCH_RESULTS',
@ -91,16 +93,12 @@ function search(
async function doSearch(
query: string,
options: {
regionCode: string;
ourNumber: string;
noteToSelf: string;
}
options: SearchOptions
): Promise<SearchResultsPayloadType> {
const { regionCode, ourNumber, noteToSelf } = options;
const { regionCode } = options;
const [discussions /*, messages */] = await Promise.all([
queryConversationsAndContacts(query, { ourNumber, noteToSelf }),
queryConversationsAndContacts(query, options),
// queryMessages(query),
]);
const { conversations, contacts } = discussions;
@ -170,23 +168,46 @@ function startNewConversation(
async function queryConversationsAndContacts(
providedQuery: string,
options: { ourNumber: string; noteToSelf: string }
options: SearchOptions
) {
const { ourNumber, noteToSelf } = options;
const { ourNumber, noteToSelf, isSecondaryDevice } = options;
const query = providedQuery.replace(/[+-.()]*/g, '');
const searchResults: Array<ConversationType> = await searchConversations(
query
);
const ourPrimaryDevice = isSecondaryDevice
? await getPrimaryDeviceFor(ourNumber)
: ourNumber;
const resultPrimaryDevices: Array<string | null> = await Promise.all(
searchResults.map(
async conversation =>
conversation.id === ourPrimaryDevice
? Promise.resolve(ourPrimaryDevice)
: getPrimaryDeviceFor(conversation.id)
)
);
// Split into two groups - active conversations and items just from address book
let conversations: Array<string> = [];
let contacts: Array<string> = [];
const max = searchResults.length;
for (let i = 0; i < max; i += 1) {
const conversation = searchResults[i];
if (conversation.type === 'direct' && !Boolean(conversation.lastMessage)) {
const primaryDevice = resultPrimaryDevices[i];
if (primaryDevice) {
if (isSecondaryDevice && primaryDevice === ourPrimaryDevice) {
conversations.push(ourNumber);
} else {
conversations.push(primaryDevice);
}
} else if (
conversation.type === 'direct' &&
!Boolean(conversation.lastMessage)
) {
contacts.push(conversation.id);
} else {
conversations.push(conversation.id);

@ -5,6 +5,7 @@ import { LocalizerType } from '../../types/Util';
export type UserStateType = {
ourNumber: string;
regionCode: string;
isSecondaryDevice: boolean;
i18n: LocalizerType;
};
@ -15,6 +16,7 @@ type UserChangedActionType = {
payload: {
ourNumber: string;
regionCode: string;
isSecondaryDevice: boolean;
};
};
@ -29,6 +31,7 @@ export const actions = {
function userChanged(attributes: {
ourNumber: string;
regionCode: string;
isSecondaryDevice: boolean;
}): UserChangedActionType {
return {
type: 'USER_CHANGED',
@ -42,6 +45,7 @@ function getEmptyState(): UserStateType {
return {
ourNumber: 'missing',
regionCode: 'missing',
isSecondaryDevice: false,
i18n: () => 'missing',
};
}

@ -21,3 +21,8 @@ export const getIntl = createSelector(
getUser,
(state: UserStateType): LocalizerType => state.i18n
);
export const getIsSecondaryDevice = createSelector(
getUser,
(state: UserStateType): boolean => state.isSecondaryDevice
);

@ -5,7 +5,12 @@ import { MainHeader } from '../../components/MainHeader';
import { StateType } from '../reducer';
import { getQuery } from '../selectors/search';
import { getIntl, getRegionCode, getUserNumber } from '../selectors/user';
import {
getIntl,
getIsSecondaryDevice,
getRegionCode,
getUserNumber,
} from '../selectors/user';
import { getMe } from '../selectors/conversations';
const mapStateToProps = (state: StateType) => {
@ -13,6 +18,7 @@ const mapStateToProps = (state: StateType) => {
searchTerm: getQuery(state),
regionCode: getRegionCode(state),
ourNumber: getUserNumber(state),
isSecondaryDevice: getIsSecondaryDevice(state),
...getMe(state),
i18n: getIntl(state),
};

@ -0,0 +1,6 @@
export type SearchOptions = {
regionCode: string;
ourNumber: string;
noteToSelf: string;
isSecondaryDevice: boolean;
};
Loading…
Cancel
Save