diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..9a92b58d5
--- /dev/null
+++ b/.vscode/launch.json
@@ -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"
+ }
+ ]
+}
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index e58951412..5b511b5f0 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -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"
},
diff --git a/app/sql.js b/app/sql.js
index 491ca5403..0be90dbd6 100644
--- a/app/sql.js
+++ b/app/sql.js
@@ -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
diff --git a/background.html b/background.html
index e3bbc00ba..1f6db1eb1 100644
--- a/background.html
+++ b/background.html
@@ -246,6 +246,52 @@
+
+
+
diff --git a/js/background.js b/js/background.js
index 710471086..2f5c7c47a 100644
--- a/js/background.js
+++ b/js/background.js
@@ -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);
diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js
index ea52a6822..c2fa998ef 100644
--- a/js/delivery_receipts.js
+++ b/js/delivery_receipts.js
@@ -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')
);
diff --git a/js/models/messages.js b/js/models/messages.js
index c0301ec05..49084a411 100644
--- a/js/models/messages.js
+++ b/js/models/messages.js
@@ -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);
diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts
index 1cec9dcb9..29e42df98 100644
--- a/js/modules/data.d.ts
+++ b/js/modules/data.d.ts
@@ -1,2 +1,3 @@
export function searchMessages(query: string): Promise>;
export function searchConversations(query: string): Promise>;
+export function getPrimaryDeviceFor(pubKey: string): Promise;
diff --git a/js/modules/data.js b/js/modules/data.js
index 14c9dddac..d56b3cbf3 100644
--- a/js/modules/data.js
+++ b/js/modules/data.js
@@ -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);
}
diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js
index 968d838e5..12155cc8a 100644
--- a/js/modules/loki_app_dot_net_api.js
+++ b/js/modules/loki_app_dot_net_api.js
@@ -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(
diff --git a/js/modules/loki_file_server_api.js b/js/modules/loki_file_server_api.js
index e460b8972..ed12c61ad 100644
--- a/js/modules/loki_file_server_api.js
+++ b/js/modules/loki_file_server_api.js
@@ -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,
diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js
index ba794adca..36d051c98 100644
--- a/js/modules/loki_message_api.js
+++ b/js/modules/loki_message_api.js
@@ -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;
diff --git a/js/registration.js b/js/registration.js
index 499e981bf..301e8cb88 100644
--- a/js/registration.js
+++ b/js/registration.js
@@ -21,6 +21,9 @@
storage.get('chromiumRegistrationDone') === ''
);
},
+ ongoingSecondaryDeviceRegistration() {
+ return storage.get('secondaryDeviceStatus') === 'ongoing';
+ },
remove() {
storage.remove('chromiumRegistrationDone');
},
diff --git a/js/rotate_signed_prekey_listener.js b/js/rotate_signed_prekey_listener.js
index 98617ecda..fe0725f29 100644
--- a/js/rotate_signed_prekey_listener.js
+++ b/js/rotate_signed_prekey_listener.js
@@ -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);
},
};
})();
diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js
index 33d07d2f1..631433d81 100644
--- a/js/signal_protocol_store.js
+++ b/js/signal_protocol_store.js
@@ -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;
})();
diff --git a/js/views/app_view.js b/js/views/app_view.js
index 31235bf9f..ad01d38c3 100644
--- a/js/views/app_view.js
+++ b/js/views/app_view.js
@@ -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);
+ },
});
})();
diff --git a/js/views/device_pairing_dialog_view.js b/js/views/device_pairing_dialog_view.js
new file mode 100644
index 000000000..0ab194776
--- /dev/null
+++ b/js/views/device_pairing_dialog_view.js
@@ -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(`${x}`);
+ });
+ }
+ } 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');
+ },
+ });
+})();
diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js
index 01bb43136..9fa7c703b 100644
--- a/js/views/inbox_view.js
+++ b/js/views/inbox_view.js
@@ -156,6 +156,7 @@
user: {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
+ isSecondaryDevice: !!window.storage.get('isSecondaryDevice'),
i18n: window.i18n,
},
};
diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js
index 027bc0815..20c5298d5 100644
--- a/js/views/standalone_registration_view.js
+++ b/js/views/standalone_registration_view.js
@@ -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();
diff --git a/libloki/api.js b/libloki/api.js
index ec5ef5688..095f2b8be 100644
--- a/libloki/api.js
+++ b/libloki/api.js
@@ -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 ...
+ // 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,
};
})();
diff --git a/libloki/crypto.js b/libloki/crypto.js
index f8ad8d7f2..a1f85d113 100644
--- a/libloki/crypto.js
+++ b/libloki/crypto.js
@@ -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,
diff --git a/libloki/storage.js b/libloki/storage.js
index 8d61881bd..733d3df92 100644
--- a/libloki/storage.js
+++ b/libloki/storage.js
@@ -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
diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js
index e12f5db41..73b458f02 100644
--- a/libtextsecure/account_manager.js
+++ b/libtextsecure/account_manager.js
@@ -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;
})();
diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js
index c7454c8e5..bac52c8eb 100644
--- a/libtextsecure/message_receiver.js
+++ b/libtextsecure/message_receiver.js
@@ -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);
diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js
index ca4142313..e098689de 100644
--- a/libtextsecure/outgoing_message.js
+++ b/libtextsecure/outgoing_message.js
@@ -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 {
diff --git a/protos/SignalService.proto b/protos/SignalService.proto
index be745b285..bfb5a362e 100644
--- a/protos/SignalService.proto
+++ b/protos/SignalService.proto
@@ -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 {
diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss
index da7f21736..6927b521d 100644
--- a/stylesheets/_global.scss
+++ b/stylesheets/_global.scss
@@ -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 {
diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx
index 8f099dfd3..cb8871f9a 100644
--- a/ts/components/MainHeader.tsx
+++ b/ts/components/MainHeader.tsx
@@ -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 {
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 {
}
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 {
}
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 {
menuItems.push(passItem('set'));
}
+ if (!isSecondaryDevice) {
+ menuItems.push({
+ id: 'pairNewDevice',
+ name: 'Device Pairing',
+ onClick: () => {
+ trigger('showDevicePairingDialog');
+ },
+ });
+ }
+
this.setState({ menuItems });
}
}
diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts
index 99c18cb27..ffd4e85e0 100644
--- a/ts/state/ducks/search.ts
+++ b/ts/state/ducks/search.ts
@@ -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 {
- 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 = await searchConversations(
query
);
+ const ourPrimaryDevice = isSecondaryDevice
+ ? await getPrimaryDeviceFor(ourNumber)
+ : ourNumber;
+
+ const resultPrimaryDevices: Array = 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 = [];
let contacts: Array = [];
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);
diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts
index 4123170cd..c49cf6386 100644
--- a/ts/state/ducks/user.ts
+++ b/ts/state/ducks/user.ts
@@ -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',
};
}
diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts
index 67478ab9f..6963ad8a3 100644
--- a/ts/state/selectors/user.ts
+++ b/ts/state/selectors/user.ts
@@ -21,3 +21,8 @@ export const getIntl = createSelector(
getUser,
(state: UserStateType): LocalizerType => state.i18n
);
+
+export const getIsSecondaryDevice = createSelector(
+ getUser,
+ (state: UserStateType): boolean => state.isSecondaryDevice
+);
diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx
index e9db0c481..1c3a8e8cc 100644
--- a/ts/state/smart/MainHeader.tsx
+++ b/ts/state/smart/MainHeader.tsx
@@ -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),
};
diff --git a/ts/types/Search.ts b/ts/types/Search.ts
new file mode 100644
index 000000000..debd559a0
--- /dev/null
+++ b/ts/types/Search.ts
@@ -0,0 +1,6 @@
+export type SearchOptions = {
+ regionCode: string;
+ ourNumber: string;
+ noteToSelf: string;
+ isSecondaryDevice: boolean;
+};