Merge pull request #623 from loki-project/multi-device

Multi device
pull/626/head
sachaaaaa 6 years ago committed by GitHub
commit 6c28b1aa79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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"
}
]
}

@ -939,6 +939,42 @@
"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"
},
"provideDeviceAlias": {
"message": "Please provide an alias for this paired device"
},
"showPairingWordsTitle": {
"message": "Pairing Secret Words"
},
"confirmUnpairingTitle": {
"message": "Please confirm you want to unpair the following device:"
},
"unpairDevice": {
"message": "Unpair Device"
},
"clear": {
"message": "Clear"
},
@ -1000,6 +1036,18 @@
"message": "Send a message",
"description": "Placeholder text in the message entry field"
},
"secondaryDeviceDefaultFR": {
"message":
"Please accept to enable messages to be synced across devices",
"description":
"Placeholder text in the message entry field when it is disabled because a secondary device conversation is visible"
},
"sendMessageDisabledSecondary": {
"message":
"This pubkey belongs to a secondary device. You should never see this message",
"description":
"Placeholder text in the message entry field when it is disabled because a secondary device conversation is visible"
},
"sendMessageDisabled": {
"message": "Waiting for friend request approval",
"description":

@ -73,6 +73,14 @@ module.exports = {
removeContactSignedPreKeyByIdentityKey,
removeAllContactSignedPreKeys,
createOrUpdatePairingAuthorisation,
removePairingAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getGrantAuthorisationsForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceFor,
getPairedDevicesFor,
createOrUpdateItem,
getItemById,
getAllItems,
@ -100,14 +108,17 @@ module.exports = {
updateConversation,
removeConversation,
getAllConversations,
getPubKeysWithFriendStatus,
getConversationsWithFriendStatus,
getAllRssFeedConversations,
getAllPublicConversations,
getPublicConversationsByServer,
getPubkeysInPublicConversation,
getPubKeysWithFriendStatus,
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
removeAllConversations,
removeAllPrivateConversations,
searchConversations,
searchMessages,
@ -781,7 +792,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) {
@ -915,6 +929,35 @@ 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,
UNIQUE(primaryDevicePubKey, secondaryDevicePubKey)
);`
);
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';"
@ -1272,7 +1315,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,
}
@ -1332,6 +1375,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);
@ -1498,12 +1649,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}`
);
}
@ -1523,7 +1675,7 @@ async function saveConversation(data) {
} = data;
await db.run(
`INSERT INTO conversations (
`INSERT INTO ${CONVERSATIONS_TABLE} (
id,
json,
@ -1587,7 +1739,7 @@ async function updateConversation(data) {
} = data;
await db.run(
`UPDATE conversations SET
`UPDATE ${CONVERSATIONS_TABLE} SET
json = $json,
active_at = $active_at,
@ -1613,7 +1765,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;
}
@ -1623,7 +1777,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
@ -1663,9 +1817,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;
@ -1675,14 +1832,17 @@ 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) {
const rows = await db.all(
`SELECT id FROM conversations WHERE
`SELECT id FROM ${CONVERSATIONS_TABLE} WHERE
friendRequestStatus = $status
AND type = 'private'
ORDER BY id ASC;`,
{
$status: status,
@ -1691,14 +1851,29 @@ async function getPubKeysWithFriendStatus(status) {
return map(rows, row => row.id);
}
async function getConversationsWithFriendStatus(status) {
const rows = await db.all(
`SELECT * FROM ${CONVERSATIONS_TABLE} WHERE
friendRequestStatus = $status
AND type = 'private'
ORDER BY id ASC;`,
{
$status: status,
}
);
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;`
);
@ -1756,7 +1931,7 @@ async function getPubkeysInPublicConversation(id) {
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;`,
@ -1770,7 +1945,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
@ -2518,6 +2693,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,72 @@
</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">
</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 id="deviceAliasView" style="display: none;" >
<input id="deviceAlias" type="text" placeholder="Device Alias" required>
</div>
<div class='buttons'>
<button class="ok" style="display: none;">{{ okText }}</button>
</div>
</div>
<!--Prompt user to confirm unpairing -->
<div class="confirmUnpairView" style="display: none;">
<h4>{{ confirmUnpairViewTitle }}</h4>
<p id="pubkey"></p>
<div class='buttons'>
<button class='cancel' tabindex='2'>{{ cancelText }}</button>
<button class='unpairDevice' tabindex='1'>{{ unpairDevice }}</button>
</div>
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='device-pairing-words-dialog'>
<div class="content">
<h4>{{ title }}</h4>
<p>{{ secretWords }}</p>
<button id='close'>{{ closeText }}</button>
</div>
</script>
<script type='text/x-tmpl-mustache' id='beta-disclaimer-dialog'>
<div class="content">
<div class="betaDisclaimerView" style="display: none;">
@ -605,21 +671,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'>
@ -640,6 +692,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 -->
@ -727,6 +813,8 @@
<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/views/device_pairing_words_dialog_view.js'></script>
<script type='text/javascript' src='js/views/create_group_dialog_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>

@ -8,6 +8,7 @@
storage,
textsecure,
Whisper,
libloki,
libsignal,
StringView,
BlockedNumberController
@ -174,6 +175,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) {
@ -184,6 +187,7 @@
const user = {
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
isSecondaryDevice: !!textsecure.storage.get('isSecondaryDevice'),
};
Whisper.events.trigger('userChanged', user);
@ -207,7 +211,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,
@ -225,28 +233,40 @@
// weird but create the object and does everything we need
conversation.getPublicSendData();
});
specialConvInited = true;
};
const initAPIs = async () => {
if (window.initialisedAPI) {
return;
}
const ourKey = textsecure.storage.user.getNumber();
window.feeds = [];
window.lokiMessageAPI = new window.LokiMessageAPI(ourKey);
// singleton to relay events to libtextsecure/message_receiver
window.lokiPublicChatAPI = new window.LokiPublicChatAPI(ourKey);
// singleton to interface the File server
window.lokiFileServerAPI = new window.LokiFileServerAPI(ourKey);
await window.lokiFileServerAPI.establishConnection(
window.getDefaultFileServer()
);
// If already exists we registered as a secondary device
if (!window.lokiFileServerAPI) {
window.lokiFileServerAPI = new window.LokiFileServerAPI(ourKey);
await window.lokiFileServerAPI.establishConnection(
window.getDefaultFileServer()
);
}
// are there limits on tracking, is this unneeded?
// window.mixpanel.track("Desktop boot");
window.lokiP2pAPI = new window.LokiP2pAPI(ourKey);
window.lokiP2pAPI.on('pingContact', pubKey => {
const isPing = true;
window.libloki.api.sendOnlineBroadcastMessage(pubKey, isPing);
libloki.api.sendOnlineBroadcastMessage(pubKey, isPing);
});
window.lokiP2pAPI.on('online', ConversationController._handleOnline);
window.lokiP2pAPI.on('offline', ConversationController._handleOffline);
window.initialisedAPI = true;
if (storage.get('isSecondaryDevice')) {
window.lokiFileServerAPI.updateOurDeviceMapping();
}
};
function mapOldThemeToNew(theme) {
@ -264,6 +284,9 @@
}
function startLocalLokiServer() {
if (window.localLokiServer) {
return;
}
const pems = window.getSelfSignedCert();
window.localLokiServer = new window.LocalLokiServer(pems);
}
@ -638,7 +661,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({
@ -801,7 +827,7 @@
});
Whisper.events.on('onEditProfile', async () => {
const ourNumber = textsecure.storage.user.getNumber();
const ourNumber = window.storage.get('primaryDevicePubKey');
const conversation = await ConversationController.getOrCreateAndWait(
ourNumber,
'private'
@ -872,6 +898,18 @@
}
});
Whisper.events.on('showDevicePairingDialog', async () => {
if (appView) {
appView.showDevicePairingDialog();
}
});
Whisper.events.on('showDevicePairingWordsDialog', async () => {
if (appView) {
appView.showDevicePairingWordsDialog();
}
});
Whisper.events.on('calculatingPoW', ({ pubKey, timestamp }) => {
try {
const conversation = ConversationController.get(pubKey);
@ -907,6 +945,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 libloki.storage.removeContactPreKeyBundle(pubKey);
await libloki.storage.removePairingAuthorisationForSecondaryPubKey(
pubKey
);
});
}
window.getSyncRequest = () =>
@ -952,14 +1006,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();
}
@ -989,7 +1043,7 @@
}
if (messageReceiver) {
messageReceiver.close();
await messageReceiver.close();
}
const USERNAME = storage.get('number_id');
@ -1004,6 +1058,31 @@
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.lokiFileServerAPI = new window.LokiFileServerAPI(ourKey);
await window.lokiFileServerAPI.establishConnection(
window.getDefaultFileServer()
);
window.localLokiServer = null;
window.lokiPublicChatAPI = null;
window.feeds = [];
messageReceiver = new textsecure.MessageReceiver(
USERNAME,
PASSWORD,
mySignalingKey,
options
);
messageReceiver.addEventListener('message', onMessageReceived);
messageReceiver.addEventListener('contact', onContactReceived);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
PASSWORD
);
return;
}
// initialize the socket and start listening for messages
startLocalLokiServer();
await initAPIs();
@ -1195,7 +1274,7 @@
ev.confirm();
}
function onTyping(ev) {
async function onTyping(ev) {
const { typing, sender, senderDevice } = ev;
const { groupId, started } = typing || {};
@ -1204,7 +1283,17 @@
return;
}
const conversation = ConversationController.get(groupId || sender);
let primaryDevice = null;
const authorisation = await libloki.storage.getGrantAuthorisationForSecondaryPubKey(
sender
);
if (authorisation) {
primaryDevice = authorisation.primaryDevicePubKey;
}
const conversation = ConversationController.get(
groupId || primaryDevice || sender
);
if (conversation) {
conversation.notifyTyping({
@ -1252,6 +1341,28 @@
if (activeAt !== null) {
activeAt = activeAt || Date.now();
}
const ourPrimaryKey = window.storage.get('primaryDevicePubKey');
const ourDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
ourPrimaryKey
);
// TODO: We should probably just *not* send any secondary devices and
// just load them all and send FRs when we get the mapping
const isOurSecondaryDevice =
id !== ourPrimaryKey &&
ourDevices &&
ourDevices.some(devicePubKey => devicePubKey === id);
if (isOurSecondaryDevice) {
await conversation.setSecondaryStatus(true, ourPrimaryKey);
}
if (conversation.isFriendRequestStatusNone()) {
// Will be replaced with automatic friend request
libloki.api.sendBackgroundMessage(conversation.id);
} else {
// Accept any pending friend requests if there are any
conversation.onAcceptFriendRequest({ blockSync: true });
}
if (details.profileKey) {
const profileKey = window.Signal.Crypto.arrayBufferToBase64(
@ -1268,12 +1379,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) {
@ -1418,6 +1536,14 @@
const messageDescriptor = getMessageDescriptor(data);
// Funnel messages to primary device conversation if multi-device
const authorisation = await 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);
@ -1425,7 +1551,10 @@
return handleProfileUpdate({ data, confirm, messageDescriptor });
}
const ourNumber = textsecure.storage.user.getNumber();
const primaryDeviceKey = window.storage.get('primaryDevicePubKey');
const allOurDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
);
const descriptorId = await textsecure.MessageReceiver.arrayBufferToString(
messageDescriptor.id
);
@ -1433,7 +1562,7 @@
if (
messageDescriptor.type === 'group' &&
descriptorId.match(/^publicChat:/) &&
data.source === ourNumber
allOurDevices.includes(data.source)
) {
// Public chat messages from ourselves should be outgoing
message = await createSentMessage(data);

@ -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')
);

@ -196,7 +196,7 @@
},
isMe() {
return this.id === this.ourNumber;
return this.id === window.storage.get('primaryDevicePubKey');
},
isPublic() {
return this.id && this.id.match(/^publicChat:/);
@ -258,9 +258,10 @@
this.trigger('message-selection-changed');
},
bumpTyping() {
async bumpTyping() {
// We don't send typing messages if the setting is disabled or we aren't friends
if (!this.isFriend() || !storage.get('typing-indicators-setting')) {
const hasFriendDevice = await this.isFriendWithAnyDevice();
if (!storage.get('typing-indicators-setting') || !hasFriendDevice) {
return;
}
@ -510,6 +511,7 @@
mentionedUs: this.get('mentionedUs') || false,
showFriendRequestIndicator: this.isPendingFriendRequest(),
isBlocked: this.isBlocked(),
isSecondary: !!this.get('secondaryStatus'),
phoneNumber: format(this.id, {
ourRegionCode: regionCode,
@ -521,7 +523,7 @@
},
isOnline: this.isOnline(),
hasNickname: !!this.getNickname(),
isFriend: this.isFriend(),
isFriend: !!this.isFriendWithAnyCache,
selectedMessages: this.selectedMessages,
@ -535,6 +537,8 @@
onDeleteMessages: () => this.deleteMessages(),
};
this.updateAsyncPropsCache();
return result;
},
@ -708,12 +712,80 @@
this.get('friendRequestStatus') === FriendRequestStatusEnum.friends
);
},
updateTextInputState() {
async getAnyDeviceFriendRequestStatus() {
const secondaryDevices = await window.libloki.storage.getSecondaryDevicesFor(
this.id
);
const allDeviceStatus = secondaryDevices
// Get all the secondary device friend status'
.map(pubKey => {
const conversation = ConversationController.get(pubKey);
if (!conversation) {
return FriendRequestStatusEnum.none;
}
return conversation.getFriendRequestStatus();
})
// Also include this conversation's friend status
.concat(this.get('friendRequestStatus'))
.reduce((acc, cur) => {
if (
acc === FriendRequestStatusEnum.friends ||
cur === FriendRequestStatusEnum.friends
) {
return FriendRequestStatusEnum.friends;
}
if (acc !== FriendRequestStatusEnum.none) {
return acc;
}
return cur;
}, FriendRequestStatusEnum.none);
return allDeviceStatus;
},
async updateAsyncPropsCache() {
const isFriendWithAnyDevice = await this.isFriendWithAnyDevice();
if (this.isFriendWithAnyCache !== isFriendWithAnyDevice) {
this.isFriendWithAnyCache = isFriendWithAnyDevice;
this.trigger('change');
}
},
async isFriendWithAnyDevice() {
const allDeviceStatus = await this.getAnyDeviceFriendRequestStatus();
return allDeviceStatus === FriendRequestStatusEnum.friends;
},
getFriendRequestStatus() {
return this.get('friendRequestStatus');
},
async getPrimaryConversation() {
if (!this.isSecondaryDevice()) {
// This is already the primary conversation
return this;
}
const authorisation = await window.libloki.storage.getAuthorisationForSecondaryPubKey(
this.id
);
if (authorisation) {
return ConversationController.getOrCreateAndWait(
authorisation.primaryDevicePubKey,
'private'
);
}
// Something funky has happened
return this;
},
async updateTextInputState() {
if (this.isRss()) {
// or if we're an rss conversation, disable it
this.trigger('disable:input', true);
return;
}
if (this.isSecondaryDevice()) {
// Or if we're a secondary device, update the primary device text input
const primaryConversation = await this.getPrimaryConversation();
primaryConversation.updateTextInputState();
return;
}
const allDeviceStatus = await this.getAnyDeviceFriendRequestStatus();
if (this.get('isKickedFromGroup')) {
this.trigger('disable:input', true);
return;
@ -723,7 +795,7 @@
this.trigger('change:placeholder', 'left-group');
return;
}
switch (this.get('friendRequestStatus')) {
switch (allDeviceStatus) {
case FriendRequestStatusEnum.none:
case FriendRequestStatusEnum.requestExpired:
this.trigger('disable:input', false);
@ -743,7 +815,25 @@
throw new Error('Invalid friend request state');
}
},
async setFriendRequestStatus(newStatus) {
isSecondaryDevice() {
return !!this.get('secondaryStatus');
},
getPrimaryDevicePubKey() {
return this.get('primaryDevicePubKey') || this.id;
},
async setSecondaryStatus(newStatus, primaryDevicePubKey) {
if (this.get('secondaryStatus') !== newStatus) {
this.set({
secondaryStatus: newStatus,
primaryDevicePubKey,
});
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
}
},
async setFriendRequestStatus(newStatus, options = {}) {
const { blockSync } = options;
// Ensure that the new status is a valid FriendStatusEnum value
if (!(newStatus in Object.values(FriendRequestStatusEnum))) {
return;
@ -759,7 +849,11 @@
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
this.updateTextInputState();
await this.updateTextInputState();
if (!blockSync && newStatus === FriendRequestStatusEnum.friends) {
// Sync contact
this.wrapSend(textsecure.messaging.sendContactSyncMessage(this));
}
}
},
async updateGroupAdmins(groupAdmins) {
@ -774,7 +868,17 @@
if (!response) {
return;
}
const pending = await this.getFriendRequests(direction, status);
const primaryConversation = ConversationController.get(
this.getPrimaryDevicePubKey()
);
// Should never happen
if (!primaryConversation) {
return;
}
const pending = await primaryConversation.getFriendRequests(
direction,
status
);
await Promise.all(
pending.map(async request => {
if (request.hasErrors()) {
@ -785,7 +889,7 @@
await window.Signal.Data.saveMessage(request.attributes, {
Message: Whisper.Message,
});
this.trigger('updateMessage', request);
primaryConversation.trigger('updateMessage', request);
})
);
},
@ -812,12 +916,12 @@
await window.libloki.storage.removeContactPreKeyBundle(this.id);
},
// We have accepted an incoming friend request
async onAcceptFriendRequest() {
async onAcceptFriendRequest(options = {}) {
if (this.unlockTimer) {
clearTimeout(this.unlockTimer);
}
if (this.hasReceivedFriendRequest()) {
this.setFriendRequestStatus(FriendRequestStatusEnum.friends);
this.setFriendRequestStatus(FriendRequestStatusEnum.friends, options);
await this.respondToAllFriendRequests({
response: 'accepted',
direction: 'incoming',
@ -906,6 +1010,13 @@
}
await this.setFriendRequestStatus(FriendRequestStatusEnum.requestSent);
},
friendRequestTimerIsExpired() {
const unlockTimestamp = this.get('unlockTimestamp');
if (unlockTimestamp && unlockTimestamp > Date.now()) {
return false;
}
return true;
},
setFriendRequestExpiryTimeout() {
if (this.isFriend()) {
return;
@ -1308,11 +1419,6 @@
},
async sendMessage(body, attachments, quote, preview) {
// Input should be blocked if there is a pending friend request
if (this.isPendingFriendRequest()) {
return;
}
this.clearTypingTimers();
const destination = this.id;
@ -1336,8 +1442,9 @@
let messageWithSchema = null;
// If we are a friend then let the user send the message normally
if (this.isFriend()) {
// If we are a friend with any of the devices, send the message normally
const canSendNormalMessage = await this.isFriendWithAnyDevice();
if (canSendNormalMessage) {
messageWithSchema = await upgradeMessageSchema({
type: 'outgoing',
body,
@ -2098,7 +2205,7 @@
read = read.filter(item => !item.hasErrors);
// Do not send read receipt if not friends yet
if (!this.isFriend()) {
if (!this.isFriendWithAnyDevice()) {
return;
}

@ -10,7 +10,8 @@
Signal,
textsecure,
Whisper,
clipboard
clipboard,
libloki,
*/
/* eslint-disable more/no-then */
@ -378,7 +379,7 @@
if (this.get('friendStatus') !== 'pending') {
return;
}
const conversation = this.getConversation();
const conversation = await this.getSourceDeviceConversation();
this.set({ friendStatus: 'accepted' });
await window.Signal.Data.saveMessage(this.attributes, {
@ -898,6 +899,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(
@ -907,12 +909,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),
@ -927,7 +937,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 {
@ -1164,6 +1175,14 @@
// the database.
return ConversationController.getUnsafe(this.get('conversationId'));
},
getSourceDeviceConversation() {
// This gets the conversation of the device that sent this message
// while getConversation will return the primary device conversation
return ConversationController.getOrCreateAndWait(
this.get('source'),
'private'
);
},
getIncomingContact() {
if (!this.isIncoming()) {
return null;
@ -1306,7 +1325,13 @@
});
this.trigger('sent', this);
this.sendSyncMessage();
if (this.get('type') !== 'friend-request') {
const c = this.getConversation();
// Don't bother sending sync messages to public chats
if (!c.isPublic()) {
this.sendSyncMessage();
}
}
})
.catch(result => {
this.trigger('done');
@ -1741,19 +1766,27 @@
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
// 3. in rare cases, an incoming message can be retried, though it will
// still go through one of the previous two codepaths
const ourNumber = textsecure.storage.user.getNumber();
const message = this;
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 (source !== ourNumber && authorisation) {
// Ignore auth from our devices
conversationId = authorisation.primaryDevicePubKey;
}
const GROUP_TYPES = textsecure.protobuf.GroupContext.Type;
const conversation = ConversationController.get(conversationId);
@ -2086,8 +2119,6 @@
c.onReadMessage(message);
}
} else {
const ourNumber = textsecure.storage.user.getNumber();
if (
message.attributes.body &&
message.attributes.body.indexOf(`@${ourNumber}`) !== -1
@ -2136,6 +2167,10 @@
});
}
const sendingDeviceConversation = await ConversationController.getOrCreateAndWait(
source,
'private'
);
if (dataMessage.profileKey) {
const profileKey = dataMessage.profileKey.toString('base64');
if (source === textsecure.storage.user.getNumber()) {
@ -2143,21 +2178,10 @@
} else if (conversation.isPrivate()) {
conversation.setProfileKey(profileKey);
} else {
ConversationController.getOrCreateAndWait(source, 'private').then(
sender => {
sender.setProfileKey(profileKey);
}
);
sendingDeviceConversation.setProfileKey(profileKey);
}
} else if (
source !== textsecure.storage.user.getNumber() &&
dataMessage.profile
) {
ConversationController.getOrCreateAndWait(source, 'private').then(
sender => {
sender.setLokiProfile(dataMessage.profile);
}
);
} else if (dataMessage.profile) {
sendingDeviceConversation.setLokiProfile(dataMessage.profile);
}
let autoAccept = false;
@ -2176,8 +2200,8 @@
- We sent the user a friend request and that user sent us a friend request.
- We are friends with the user, and that user just sent us a friend request.
*/
const isFriend = conversation.isFriend();
const hasSentFriendRequest = conversation.hasSentFriendRequest();
const isFriend = sendingDeviceConversation.isFriend();
const hasSentFriendRequest = sendingDeviceConversation.hasSentFriendRequest();
autoAccept = isFriend || hasSentFriendRequest;
if (autoAccept) {
@ -2187,12 +2211,13 @@
if (isFriend) {
window.Whisper.events.trigger('endSession', source);
} else if (hasSentFriendRequest) {
await conversation.onFriendRequestAccepted();
await sendingDeviceConversation.onFriendRequestAccepted();
} else {
await conversation.onFriendRequestReceived();
await sendingDeviceConversation.onFriendRequestReceived();
}
} else {
await conversation.onFriendRequestAccepted();
} else if (message.get('type') !== 'outgoing') {
// Ignore 'outgoing' messages because they are sync messages
await sendingDeviceConversation.onFriendRequestAccepted();
// We need to return for these types of messages because android struggles
if (
!message.get('body') &&

@ -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, clearTimeout, IDBKeyRange */
/* global window, setTimeout, clearTimeout, IDBKeyRange, dcodeIO */
const electron = require('electron');
@ -13,6 +13,7 @@ const {
map,
set,
omit,
isArrayBuffer,
} = require('lodash');
const _ = require('lodash');
@ -91,6 +92,15 @@ module.exports = {
removeContactSignedPreKeyByIdentityKey,
removeAllContactSignedPreKeys,
createOrUpdatePairingAuthorisation,
removePairingAuthorisationForSecondaryPubKey,
getGrantAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getGrantAuthorisationsForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceFor,
getPairedDevicesFor,
createOrUpdateItem,
getItemById,
getAllItems,
@ -119,6 +129,7 @@ module.exports = {
getAllConversations,
getPubKeysWithFriendStatus,
getConversationsWithFriendStatus,
getAllConversationIds,
getAllPrivateConversations,
getAllRssFeedConversations,
@ -181,6 +192,8 @@ module.exports = {
removeAll,
removeAllConfiguration,
removeAllConversations,
removeAllPrivateConversations,
removeOtherData,
cleanupOrphanedAttachments,
@ -585,6 +598,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 = {
@ -729,8 +799,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 }) {
@ -1111,6 +1193,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);
}

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

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

@ -468,6 +468,9 @@
case 'disabled':
placeholder = i18n('sendMessageDisabled');
break;
case 'secondary':
placeholder = i18n('sendMessageDisabledSecondary');
break;
case 'left-group':
placeholder = i18n('sendMessageLeftGroup');
break;

@ -0,0 +1,207 @@
/* global
Whisper,
i18n,
libloki,
textsecure,
ConversationController,
$,
lokiFileServerAPI
*/
// 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;
this.pubKeyToUnpair = null;
this.success = false;
},
events: {
'click #startPairing': 'startReceivingRequests',
'click #close': 'close',
'click .waitingForRequestView .cancel': 'stopReceivingRequests',
'click .requestReceivedView .skip': 'skipDevice',
'click #allowPairing': 'allowDevice',
'click .requestAcceptedView .ok': 'stopReceivingRequests',
'click .confirmUnpairView .cancel': 'stopReceivingRequests',
'click .confirmUnpairView .unpairDevice': 'confirmUnpairDevice',
},
render_attributes() {
return {
defaultTitle: i18n('pairedDevices'),
waitingForRequestTitle: i18n('waitingForDeviceToRegister'),
requestReceivedTitle: i18n('devicePairingReceived'),
requestAcceptedTitle: i18n('devicePairingAccepted'),
startPairingText: i18n('pairNewDevice'),
cancelText: i18n('cancel'),
unpairDevice: i18n('unpairDevice'),
closeText: i18n('close'),
skipText: i18n('skip'),
okText: i18n('ok'),
allowPairingText: i18n('allowPairing'),
confirmUnpairViewTitle: i18n('confirmUnpairingTitle'),
};
},
startReceivingRequests() {
this.trigger('startReceivingRequests');
this.isListening = true;
this.showView();
},
stopReceivingRequests() {
if (this.success) {
const deviceAlias = this.$('#deviceAlias')[0].value.trim();
const conv = ConversationController.get(this.pubKey);
if (conv) {
conv.setNickname(deviceAlias);
}
}
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('provideDeviceAlias'));
this.$('#deviceAliasView').show();
this.$('#deviceAlias').on('input', e => {
if (e.target.value.trim()) {
this.$('.requestAcceptedView .ok').removeAttr('disabled');
} else {
this.$('.requestAcceptedView .ok').attr('disabled', true);
}
});
this.$('.requestAcceptedView .ok').show();
this.$('.requestAcceptedView .ok').attr('disabled', true);
this.success = true;
} 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 confirmUnpairDevice() {
await libloki.storage.removePairingAuthorisationForSecondaryPubKey(
this.pubKeyToUnpair
);
await lokiFileServerAPI.updateOurDeviceMapping();
this.reset();
this.showView();
},
requestUnpairDevice(pubKey) {
this.pubKeyToUnpair = pubKey;
this.showView();
},
getPubkeyName(pubKey) {
const secretWords = window.mnemonic.pubkey_to_secret_words(pubKey);
const conv = ConversationController.get(pubKey);
const deviceAlias = conv ? conv.getNickname() : 'Unnamed Device';
return `${deviceAlias} (pairing secret: <i>${secretWords}</i>)`;
},
async showView() {
const defaultView = this.$('.defaultView');
const waitingForRequestView = this.$('.waitingForRequestView');
const requestReceivedView = this.$('.requestReceivedView');
const requestAcceptedView = this.$('.requestAcceptedView');
const confirmUnpairView = this.$('.confirmUnpairView');
if (this.pubKeyToUnpair) {
defaultView.hide();
requestReceivedView.hide();
waitingForRequestView.hide();
requestAcceptedView.hide();
confirmUnpairView.show();
const name = this.getPubkeyName(this.pubKeyToUnpair);
this.$('.confirmUnpairView #pubkey').html(name);
} else if (!this.isListening) {
requestReceivedView.hide();
waitingForRequestView.hide();
requestAcceptedView.hide();
confirmUnpairView.hide();
const ourPubKey = textsecure.storage.user.getNumber();
defaultView.show();
const pubKeys = await libloki.storage.getSecondaryDevicesFor(ourPubKey);
this.$('#pairedPubKeys').empty();
if (pubKeys && pubKeys.length > 0) {
this.$('#startPairing').attr('disabled', true);
pubKeys.forEach(x => {
const name = this.getPubkeyName(x);
const li = $('<li>').html(name);
if (window.lokiFeatureFlags.multiDeviceUnpairing) {
const link = $('<a>')
.text('Unpair')
.attr('href', '#');
link.on('click', () => this.requestUnpairDevice(x));
li.append(' - ');
li.append(link);
}
this.$('#pairedPubKeys').append(li);
});
} else {
this.$('#startPairing').removeAttr('disabled');
this.$('#pairedPubKeys').append('<li>No paired devices</li>');
}
} else if (this.accepted) {
defaultView.hide();
requestReceivedView.hide();
waitingForRequestView.hide();
requestAcceptedView.show();
} else if (this.pubKey) {
const secretWords = window.mnemonic.pubkey_to_secret_words(this.pubKey);
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');
},
});
})();

@ -0,0 +1,35 @@
/* global Whisper, i18n, textsecure */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.DevicePairingWordsDialogView = Whisper.View.extend({
className: 'loki-dialog device-pairing-words-dialog modal',
templateName: 'device-pairing-words-dialog',
initialize() {
const pubKey = textsecure.storage.user.getNumber();
this.secretWords = window.mnemonic
.mn_encode(pubKey.slice(2), 'english')
.split(' ')
.slice(-3)
.join(' ');
this.render();
},
events: {
'click #close': 'close',
},
render_attributes() {
return {
title: i18n('showPairingWordsTitle'),
closeText: i18n('close'),
secretWords: this.secretWords,
};
},
close() {
this.remove();
},
});
})();

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

@ -1,5 +1,14 @@
/* global Whisper, $, getAccountManager, textsecure,
i18n, passwordUtil, _, setTimeout, displayNameRegex */
/* global
Whisper,
$,
getAccountManager,
textsecure,
i18n,
passwordUtil,
_,
setTimeout,
displayNameRegex
*/
/* eslint-disable more/no-then */
@ -18,6 +27,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();
@ -31,6 +42,7 @@
this.$('#error').hide();
this.$('.standalone-mnemonic').hide();
this.$('.standalone-secondary-device').hide();
this.onGenerateMnemonic();
@ -54,10 +66,19 @@
this.registrationParams = {};
this.$pages = this.$('.page');
this.pairingInterval = null;
this.showRegisterPage();
this.onValidatePassword();
this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind(
this
);
this.$('#display-name').get(0).oninput = () => {
this.sanitiseNameInput();
};
this.$('#display-name').get(0).onpaste = () => {
// Sanitise data immediately after paste because it's easier
setTimeout(() => {
@ -74,6 +95,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',
@ -150,7 +173,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,
@ -170,6 +198,97 @@
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.pubkey_to_secret_words(pubkey);
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, ConversationController */
// eslint-disable-next-line func-names
(function() {
@ -27,19 +27,33 @@
}
async function sendOnlineBroadcastMessage(pubKey, isPing = false) {
const authorisation = await window.libloki.storage.getGrantAuthorisationForSecondaryPubKey(
pubKey
);
if (authorisation && authorisation.primaryDevicePubKey !== pubKey) {
sendOnlineBroadcastMessage(authorisation.primaryDevicePubKey);
return;
}
let p2pAddress = null;
let p2pPort = null;
let type;
if (!window.localLokiServer.isListening()) {
type = textsecure.protobuf.LokiAddressMessage.Type.HOST_UNREACHABLE;
} else {
// clearnet change: getMyLokiAddress -> getMyClearIP
// const myLokiAddress = await window.lokiSnodeAPI.getMyLokiAddress();
const myIp = await window.lokiSnodeAPI.getMyClearIp();
let myIp;
if (window.localLokiServer && window.localLokiServer.isListening()) {
try {
// clearnet change: getMyLokiAddress -> getMyClearIP
// const myLokiAddress = await window.lokiSnodeAPI.getMyLokiAddress();
myIp = await window.lokiSnodeAPI.getMyClearIp();
} catch (e) {
log.warn(`Failed to get clear IP for local server ${e}`);
}
}
if (myIp) {
p2pAddress = `https://${myIp}`;
p2pPort = window.localLokiServer.getPublicPort();
type = textsecure.protobuf.LokiAddressMessage.Type.HOST_REACHABLE;
} else {
type = textsecure.protobuf.LokiAddressMessage.Type.HOST_UNREACHABLE;
}
const lokiAddressMessage = new textsecure.protobuf.LokiAddressMessage({
@ -65,9 +79,153 @@
await outgoingMessage.sendToNumber(pubKey);
}
function createPairingAuthorisationProtoMessage({
primaryDevicePubKey,
secondaryDevicePubKey,
requestSignature,
grantSignature,
}) {
if (!primaryDevicePubKey || !secondaryDevicePubKey || !requestSignature) {
throw new Error(
'createPairingAuthorisationProtoMessage: pubkeys missing'
);
}
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,
});
}
// 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.writeInt32(buffer.limit);
result.append(buffer);
});
result.limit = result.offset;
result.reset();
return result;
}
async function createContactSyncProtoMessage(conversations) {
// Extract required contacts information out of conversations
const rawContacts = await Promise.all(
conversations.map(async conversation => {
const profile = conversation.getLokiProfile();
const number = conversation.getNumber();
const name = profile
? profile.displayName
: conversation.getProfileName();
const status = await 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
);
const ourNumber = textsecure.storage.user.getNumber();
const ourConversation = await ConversationController.getOrCreateAndWait(
ourNumber,
'private'
);
const content = new textsecure.protobuf.Content({
pairingAuthorisation,
});
const isGrant = authorisation.primaryDevicePubKey === ourNumber;
if (isGrant) {
// Send profile name to secondary device
const lokiProfile = ourConversation.getLokiProfile();
const profile = new textsecure.protobuf.DataMessage.LokiProfile(
lokiProfile
);
const dataMessage = new textsecure.protobuf.DataMessage({
profile,
});
// Attach contact list
const conversations = await window.Signal.Data.getConversationsWithFriendStatus(
window.friends.friendRequestStatusEnum.friends,
{ ConversationCollection: Whisper.ConversationCollection }
);
const syncMessage = await createContactSyncProtoMessage(conversations);
content.syncMessage = syncMessage;
content.dataMessage = dataMessage;
}
// 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,
};
})();

@ -159,6 +159,129 @@
}
}
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 verifyAuthorisation(authorisation) {
const {
primaryDevicePubKey,
secondaryDevicePubKey,
requestSignature,
grantSignature,
} = authorisation;
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;
}
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, PairingType.REQUEST);
} catch (e) {
window.log.warn(
'Could not verify pairing request authorisation signature. Ignoring message.'
);
window.log.error(e);
return false;
}
// can't have grant without requestSignature?
if (isGrant) {
try {
await verify(grantSignature, PairingType.GRANT);
} catch (e) {
window.log.warn(
'Could not verify pairing grant authorisation signature. Ignoring message.'
);
window.log.error(e);
return false;
}
}
return true;
}
// FIXME: rename to include the fact it's relative to YOUR device
async function validateAuthorisation(authorisation) {
const {
primaryDevicePubKey,
secondaryDevicePubKey,
grantSignature,
} = authorisation;
const alreadySecondaryDevice = !!window.storage.get('isSecondaryDevice');
const ourPubKey = textsecure.storage.user.getNumber();
const isRequest = !grantSignature;
if (isRequest && alreadySecondaryDevice) {
window.log.warn(
'Received a pairing request while being a secondary device. Ignored.'
);
return false;
} else if (isRequest && primaryDevicePubKey !== ourPubKey) {
window.log.warn(
'Received a pairing request addressed to another pubkey. Ignored.'
);
return false;
} else if (isRequest && secondaryDevicePubKey === ourPubKey) {
window.log.warn('Received a pairing request from ourselves. Ignored.');
return false;
}
return this.verifyAuthorisation(authorisation);
}
async function verifyPairingSignature(
primaryDevicePubKey,
secondaryPubKey,
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 === PairingType.GRANT) {
data.set(new Uint8Array(secondaryPubKeyArrayBuffer));
issuer = primaryDevicePubKeyArrayBuffer;
} else if (type === PairingType.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()
@ -178,11 +301,15 @@
const tokenString = dcodeIO.ByteBuffer.wrap(token).toString('utf8');
return tokenString;
}
const snodeCipher = new LokiSnodeChannel();
const sha512 = data => crypto.subtle.digest('SHA-512', data);
const PairingType = Object.freeze({
REQUEST: 1,
GRANT: 2,
});
window.libloki.crypto = {
DHEncrypt,
DHDecrypt,
@ -190,6 +317,11 @@
FallBackDecryptionError,
snodeCipher,
decryptToken,
generateSignatureForPairing,
verifyPairingSignature,
verifyAuthorisation,
validateAuthorisation,
PairingType,
// for testing
_LokiSnodeChannel: LokiSnodeChannel,
_decodeSnodeAddressToPubKey: decodeSnodeAddressToPubKey,

@ -6,6 +6,7 @@ module.exports = {
mn_decode,
sc_reduce32,
get_languages,
pubkey_to_secret_words,
};
class MnemonicError extends Error {}
@ -190,3 +191,10 @@ for (var i in mn_words) {
}
}
}
function pubkey_to_secret_words(pubKey) {
return mn_encode(pubKey.slice(2), 'english')
.split(' ')
.slice(0, 3)
.join(' ');
}

@ -1,9 +1,13 @@
/* global window, libsignal, textsecure */
/* global window, libsignal, textsecure, Signal,
lokiFileServerAPI, ConversationController */
// eslint-disable-next-line func-names
(function() {
window.libloki = window.libloki || {};
const timers = {};
const REFRESH_DELAY = 60 * 1000;
async function getPreKeyBundleForContact(pubKey) {
const myKeyPair = await textsecure.storage.protocol.getIdentityKeyPair();
const identityKey = myKeyPair.pubKey;
@ -113,11 +117,142 @@
}
}
// fetches device mappings from server.
async function getPrimaryDeviceMapping(pubKey) {
if (typeof lokiFileServerAPI === 'undefined') {
// If this is not defined then we are initiating a pairing
return [];
}
const deviceMapping = await lokiFileServerAPI.getUserDeviceMapping(pubKey);
if (!deviceMapping) {
return [];
}
let authorisations = deviceMapping.authorisations || [];
if (deviceMapping.isPrimary === '0') {
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.
const primaryDeviceMapping = await lokiFileServerAPI.getUserDeviceMapping(
primaryDevicePubKey
);
if (!primaryDeviceMapping) {
return [];
}
({ authorisations } = primaryDeviceMapping);
}
}
return authorisations || [];
}
// if the device is a secondary device,
// fetch the device mappings for its primary device
async function saveAllPairingAuthorisationsFor(pubKey) {
// Will be false if there is no timer
const cacheValid = timers[pubKey] > Date.now();
if (cacheValid) {
return;
}
timers[pubKey] = Date.now() + REFRESH_DELAY;
const authorisations = await getPrimaryDeviceMapping(pubKey);
await Promise.all(
authorisations.map(authorisation =>
savePairingAuthorisation(authorisation)
)
);
}
async function savePairingAuthorisation(authorisation) {
// Ensure that we have a conversation for all the devices
const conversation = await ConversationController.getOrCreateAndWait(
authorisation.secondaryDevicePubKey,
'private'
);
await conversation.setSecondaryStatus(
true,
authorisation.primaryDevicePubKey
);
await 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 conversation = ConversationController.get(secondaryPubKey);
if (!conversation || conversation.isPublic() || conversation.isRss()) {
return null;
}
await saveAllPairingAuthorisationsFor(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) {
await saveAllPairingAuthorisationsFor(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) {
await saveAllPairingAuthorisationsFor(primaryDevicePubKey);
const secondaryPubKeys =
(await getSecondaryDevicesFor(primaryDevicePubKey)) || [];
return secondaryPubKeys.concat(primaryDevicePubKey);
}
window.libloki.storage = {
getPreKeyBundleForContact,
saveContactPreKeyBundle,
removeContactPreKeyBundle,
verifyFriendRequestAcceptPreKey,
savePairingAuthorisation,
saveAllPairingAuthorisationsFor,
removePairingAuthorisationForSecondaryPubKey,
getGrantAuthorisationForSecondaryPubKey,
getAuthorisationForSecondaryPubKey,
getAllDevicePubKeysForPrimaryPubKey,
getSecondaryDevicesFor,
getPrimaryDeviceMapping,
};
// 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 */
@ -543,6 +546,10 @@
async registrationDone(number, displayName) {
window.log.info('registration done');
if (!textsecure.storage.get('secondaryDeviceStatus')) {
// We have registered as a primary device
textsecure.storage.put('primaryDevicePubKey', number);
}
// Ensure that we always have a conversation for ourself
const conversation = await ConversationController.getOrCreateAndWait(
number,
@ -552,6 +559,91 @@
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 requestSignature = await libloki.crypto.generateSignatureForPairing(
primaryDevicePubKey,
libloki.crypto.PairingType.REQUEST
);
const authorisation = {
primaryDevicePubKey,
secondaryDevicePubKey: ourPubKey,
requestSignature,
};
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
const secondaryConversation = await ConversationController.getOrCreateAndWait(
secondaryDevicePubKey,
'private'
);
const grantSignature = await libloki.crypto.generateSignatureForPairing(
secondaryDevicePubKey,
libloki.crypto.PairingType.GRANT
);
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,
};
// Update authorisation in database with the new grant signature
await libloki.storage.savePairingAuthorisation(authorisation);
await lokiFileServerAPI.updateOurDeviceMapping();
await libloki.api.sendPairingAuthorisation(
authorisation,
secondaryDevicePubKey
);
// Always be friends with secondary devices
await secondaryConversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends,
{
blockSync: true,
}
);
},
validatePubKeyHex(pubKey) {
const c = new Whisper.Conversation({
id: pubKey,
type: 'private',
});
const validationError = c.validateNumber();
if (validationError) {
throw new Error(validationError);
}
},
});
textsecure.AccountManager = AccountManager;
})();

@ -14,7 +14,7 @@ ProtoParser.prototype = {
if (this.buffer.limit === this.buffer.offset) {
return undefined; // eof
}
const len = this.buffer.readVarint32();
const len = this.buffer.readInt32();
const nextBuffer = this.buffer
.slice(this.buffer.offset, this.buffer.offset + len)
.toArrayBuffer();

@ -18,7 +18,10 @@
/* global lokiMessageAPI: false */
/* global lokiP2pAPI: false */
/* global feeds: false */
/* global Whisper: false */
/* global lokiFileServerAPI: false */
/* global WebAPI: false */
/* global ConversationController: false */
/* eslint-disable more/no-then */
/* eslint-disable no-unreachable */
@ -78,11 +81,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));
@ -119,6 +126,9 @@ MessageReceiver.prototype.extend({
this.incoming = [this.pending];
},
async startLocalServer() {
if (!localLokiServer) {
return;
}
try {
// clearnet change: getMyLokiIp -> getMyClearIp
// const myLokiIp = await window.lokiSnodeAPI.getMyLokiIp();
@ -185,7 +195,7 @@ MessageReceiver.prototype.extend({
);
}
},
close() {
async close() {
window.log.info('MessageReceiver.close()');
this.calledClose = true;
@ -199,6 +209,10 @@ MessageReceiver.prototype.extend({
localLokiServer.close();
}
if (lokiPublicChatAPI) {
await lokiPublicChatAPI.close();
}
if (this.httpPollingResource) {
this.httpPollingResource.close();
}
@ -1005,7 +1019,9 @@ MessageReceiver.prototype.extend({
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isMe = envelope.source === textsecure.storage.user.getNumber();
const isMe =
envelope.source === textsecure.storage.user.getNumber() ||
envelope.source === window.storage.get('primaryDevicePubKey');
const isLeavingGroup = Boolean(
message.group &&
message.group.type === textsecure.protobuf.GroupContext.Type.QUIT
@ -1048,6 +1064,170 @@ 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 { primaryDevicePubKey, grantSignature } = pairingAuthorisation;
if (grantSignature) {
// Authorisation received to become a secondary device
window.log.info(
`Received pairing authorisation from ${primaryDevicePubKey}`
);
// 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);
window.storage.put('primaryDevicePubKey', primaryDevicePubKey);
await libloki.storage.savePairingAuthorisation(pairingAuthorisation);
const primaryConversation = await ConversationController.getOrCreateAndWait(
primaryDevicePubKey,
'private'
);
primaryConversation.trigger('change');
Whisper.events.trigger('secondaryDeviceRegistration');
// Update profile name
if (dataMessage && dataMessage.profile) {
const ourNumber = window.storage.get('primaryDevicePubKey');
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;
}
} 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 { secondaryDevicePubKey, grantSignature } = pairingAuthorisation;
const isGrant =
grantSignature &&
secondaryDevicePubKey === textsecure.storage.user.getNumber();
if (isGrant) {
return this.handleAuthorisationForSelf(
envelope,
pairingAuthorisation,
content
);
}
return this.handlePairingRequest(envelope, pairingAuthorisation);
},
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.isFriendWithAnyDevice()) {
return false;
}
await libloki.storage.savePairingAuthorisation(authorisation);
return true;
},
handleDataMessage(envelope, msg) {
if (!envelope.isP2p) {
const timestamp = envelope.timestamp.toNumber();
@ -1086,9 +1266,31 @@ MessageReceiver.prototype.extend({
}
}
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) {
// sending a message back = accepting friend request
// Directly setting friend request status to skip the pending state
await conversation.setFriendRequestStatus(
window.friends.friendRequestStatusEnum.friends
);
window.libloki.api.sendBackgroundMessage(envelope.source);
return this.removeFromCache(envelope);
}
}
}
if (groupId && isBlocked && !(isMe && isLeavingGroup)) {
@ -1158,6 +1360,9 @@ MessageReceiver.prototype.extend({
content.lokiAddressMessage
);
}
if (content.pairingAuthorisation) {
return this.handlePairingAuthorisationMessage(envelope, content);
}
if (content.syncMessage) {
return this.handleSyncMessage(envelope, content.syncMessage);
}
@ -1255,13 +1460,18 @@ MessageReceiver.prototype.extend({
window.log.info('null message from', this.getEnvelopeId(envelope));
this.removeFromCache(envelope);
},
handleSyncMessage(envelope, syncMessage) {
if (envelope.source !== this.number) {
throw new Error('Received sync message from another number');
}
// eslint-disable-next-line eqeqeq
if (envelope.sourceDevice == this.deviceId) {
throw new Error('Received sync message from our own device');
async handleSyncMessage(envelope, syncMessage) {
const ourNumber = textsecure.storage.user.getNumber();
// NOTE: Maybe we should be caching this list?
const ourDevices = await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
window.storage.get('primaryDevicePubKey')
);
const validSyncSender =
ourDevices && ourDevices.some(devicePubKey => devicePubKey === ourNumber);
if (!validSyncSender) {
throw new Error(
"Received sync message from a device we aren't paired with"
);
}
if (syncMessage.sent) {
const sentMessage = syncMessage.sent;
@ -1329,11 +1539,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();
@ -1462,8 +1672,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);

@ -6,8 +6,8 @@
libloki,
StringView,
dcodeIO,
log,
lokiMessageAPI,
i18n,
*/
/* eslint-disable more/no-then */
@ -95,19 +95,21 @@ OutgoingMessage.prototype = {
this.numberCompleted();
},
reloadDevicesAndSend(number, recurse) {
const ourNumber = textsecure.storage.user.getNumber();
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)
// Don't send to ourselves
.then(devicesPubKeys =>
devicesPubKeys.filter(pubKey => pubKey !== ourNumber)
)
.then(devicesPubKeys => {
if (devicesPubKeys.length === 0) {
// eslint-disable-next-line no-param-reassign
devicesPubKeys = [number];
}
return this.doSendMessage(number, devicesPubKeys, recurse);
});
},
getKeysForNumber(number, updateDevices) {
@ -234,8 +236,7 @@ OutgoingMessage.prototype = {
return messagePartCount * 160;
},
convertMessageToText(message) {
const messageBuffer = message.toArrayBuffer();
convertMessageToText(messageBuffer) {
const plaintext = new Uint8Array(
this.getPaddedMessageLength(messageBuffer.byteLength + 1) - 1
);
@ -244,11 +245,8 @@ OutgoingMessage.prototype = {
return plaintext;
},
getPlaintext() {
if (!this.plaintext) {
this.plaintext = this.convertMessageToText(this.message);
}
return this.plaintext;
getPlaintext(messageBuffer) {
return this.convertMessageToText(messageBuffer);
},
async wrapInWebsocketMessage(outgoingObject) {
const messageEnvelope = new textsecure.protobuf.Envelope({
@ -271,7 +269,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 +287,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,41 +320,109 @@ 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 updatedDevices = await this.getStaleDeviceIdsForNumber(
devicePubKey
);
const keysFound = await this.getKeysForNumber(
devicePubKey,
updatedDevices
);
let enableFallBackEncryption = !keysFound;
const address = new libsignal.SignalProtocolAddress(
devicePubKey,
deviceId
);
const ourKey = textsecure.storage.user.getNumber();
const options = {};
const fallBackCipher = new libloki.crypto.FallBackSessionCipher(
address
);
let isMultiDeviceRequest = false;
let thisDeviceMessageType = this.messageType;
if (
thisDeviceMessageType !== 'pairing-request' &&
thisDeviceMessageType !== 'friend-request'
) {
let conversation;
try {
conversation = ConversationController.get(devicePubKey);
} catch (e) {
// do nothing
}
if (
conversation &&
!conversation.isFriend() &&
!conversation.hasReceivedFriendRequest()
) {
// We want to send an automated friend request if:
// - We aren't already friends
// - We haven't received a friend request from this device
// - We haven't sent a friend request recently
if (conversation.friendRequestTimerIsExpired()) {
isMultiDeviceRequest = true;
thisDeviceMessageType = 'friend-request';
} else {
// Throttle automated friend requests
this.successfulNumbers.push(devicePubKey);
return null;
}
}
}
// Check if we need to attach the preKeys
let sessionCipher;
const isFriendRequest = this.messageType === 'friend-request';
const isFriendRequest = thisDeviceMessageType === 'friend-request';
enableFallBackEncryption =
enableFallBackEncryption || isFriendRequest || isMultiDeviceRequest;
const flags = this.message.dataMessage
? this.message.dataMessage.get_flags()
: null;
const isEndSession =
flags === textsecure.protobuf.DataMessage.Flags.END_SESSION;
if (isFriendRequest || isEndSession) {
const signalCipher = new libsignal.SessionCipher(
textsecure.storage.protocol,
address,
options
);
if (enableFallBackEncryption || isEndSession) {
// Encrypt them with the fallback
const pkb = await libloki.storage.getPreKeyBundleForContact(number);
const pkb = await libloki.storage.getPreKeyBundleForContact(
devicePubKey
);
const preKeyBundleMessage = new textsecure.protobuf.PreKeyBundleMessage(
pkb
);
this.message.preKeyBundleMessage = preKeyBundleMessage;
window.log.info('attaching prekeys to outgoing message');
}
if (isFriendRequest) {
let messageBuffer;
if (isMultiDeviceRequest) {
const tempMessage = new textsecure.protobuf.Content();
const tempDataMessage = new textsecure.protobuf.DataMessage();
tempDataMessage.body = i18n('secondaryDeviceDefaultFR');
if (this.message.dataMessage && this.message.dataMessage.profile) {
tempDataMessage.profile = this.message.dataMessage.profile;
}
tempMessage.preKeyBundleMessage = this.message.preKeyBundleMessage;
tempMessage.dataMessage = tempDataMessage;
messageBuffer = tempMessage.toArrayBuffer();
} else {
messageBuffer = this.message.toArrayBuffer();
}
if (enableFallBackEncryption) {
sessionCipher = fallBackCipher;
} else {
sessionCipher = new libsignal.SessionCipher(
textsecure.storage.protocol,
address,
options
);
sessionCipher = signalCipher;
}
const plaintext = this.getPlaintext();
const plaintext = this.getPlaintext(messageBuffer);
// No limit on message keys if we're communicating with our other devices
if (ourKey === number) {
@ -365,23 +433,27 @@ OutgoingMessage.prototype = {
// Encrypt our plain text
const ciphertext = await sessionCipher.encrypt(plaintext);
if (!this.fallBackEncryption) {
if (!enableFallBackEncryption) {
// eslint-disable-next-line no-param-reassign
ciphertext.body = new Uint8Array(
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(thisDeviceMessageType);
return {
type: ciphertext.type, // FallBackSessionCipher sets this to FRIEND_REQUEST
@ -390,20 +462,54 @@ 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 => {
if (!outgoingObject) {
return;
}
const destination = outgoingObject.pubKey;
try {
const socketMessage = await this.wrapInWebsocketMessage(
outgoingObject
);
await this.transmitMessage(
destination,
socketMessage,
this.timestamp,
outgoingObject.ttl
);
if (
outgoingObject.type ===
textsecure.protobuf.Envelope.Type.FRIEND_REQUEST
) {
const conversation = ConversationController.get(destination);
if (conversation) {
// Redundant for primary device but marks secondary devices as pending
await conversation.onFriendRequestSent();
}
}
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 +565,7 @@ OutgoingMessage.prototype = {
window.log.error(
'Got "key changed" error from encrypt - no identityKey for application layer',
number,
deviceIds
devicesPubKeys
);
throw error;
} else {
@ -512,36 +618,25 @@ OutgoingMessage.prototype = {
} catch (e) {
// do nothing
}
return this.getStaleDeviceIdsForNumber(number).then(updateDevices =>
this.getKeysForNumber(number, updateDevices)
.then(async keysFound => {
if (!keysFound) {
log.info('Fallback encryption enabled');
this.fallBackEncryption = true;
}
})
.then(this.reloadDevicesAndSend(number, true))
.catch(error => {
conversation.resetPendingSend();
if (error.message === 'Identity key changed') {
// eslint-disable-next-line no-param-reassign
error = new textsecure.OutgoingIdentityKeyError(
number,
error.originalMessage,
error.timestamp,
error.identityKey
);
this.registerError(number, 'Identity key changed', error);
} else {
this.registerError(
number,
`Failed to retrieve new device keys for number ${number}`,
error
);
}
})
);
return this.reloadDevicesAndSend(number, true)().catch(error => {
conversation.resetPendingSend();
if (error.message === 'Identity key changed') {
// eslint-disable-next-line no-param-reassign
error = new textsecure.OutgoingIdentityKeyError(
number,
error.originalMessage,
error.timestamp,
error.identityKey
);
this.registerError(number, 'Identity key changed', error);
} else {
this.registerError(
number,
`Failed to retrieve new device keys for number ${number}`,
error
);
}
});
},
};

@ -1,4 +1,4 @@
/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window */
/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window, libloki */
/* eslint-disable more/no-then, no-bitwise */
@ -391,6 +391,7 @@ MessageSender.prototype = {
);
if (
number === textsecure.storage.user.getNumber() ||
haveSession ||
options.isPublic ||
options.messageType === 'friend-request'
@ -456,7 +457,7 @@ MessageSender.prototype = {
return syncMessage;
},
sendSyncMessage(
async sendSyncMessage(
encodedDataMessage,
timestamp,
destination,
@ -465,10 +466,16 @@ MessageSender.prototype = {
unidentifiedDeliveries = [],
options
) {
const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId();
if (myDevice === 1 || myDevice === '1') {
return Promise.resolve();
const primaryDeviceKey =
window.storage.get('primaryDevicePubKey') ||
textsecure.storage.user.getNumber();
const allOurDevices = (await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
))
// Don't send to ourselves
.filter(pubKey => pubKey !== textsecure.storage.user.getNumber());
if (allOurDevices.length === 0) {
return null;
}
const dataMessage = textsecure.protobuf.DataMessage.decode(
@ -511,7 +518,7 @@ MessageSender.prototype = {
const silent = true;
return this.sendIndividualProto(
myNumber,
primaryDeviceKey,
contentMessage,
Date.now(),
silent,
@ -579,6 +586,38 @@ MessageSender.prototype = {
return Promise.resolve();
},
async sendContactSyncMessage(contactConversation) {
const primaryDeviceKey = window.storage.get('primaryDevicePubKey');
const allOurDevices = (await libloki.storage.getAllDevicePubKeysForPrimaryPubKey(
primaryDeviceKey
))
// Don't send to ourselves
.filter(pubKey => pubKey !== textsecure.storage.user.getNumber());
if (
allOurDevices.includes(contactConversation.id) ||
!primaryDeviceKey ||
allOurDevices.length === 0
) {
// If we havn't got a primaryDeviceKey then we are in the middle of pairing
return Promise.resolve();
}
const syncMessage = await libloki.api.createContactSyncProtoMessage([
contactConversation,
]);
const contentMessage = new textsecure.protobuf.Content();
contentMessage.syncMessage = syncMessage;
const silent = true;
return this.sendIndividualProto(
primaryDeviceKey,
contentMessage,
Date.now(),
silent,
{} // options
);
},
sendRequestContactSyncMessage(options) {
const myNumber = textsecure.storage.user.getNumber();
const myDevice = textsecure.storage.user.getDeviceId();
@ -610,7 +649,8 @@ MessageSender.prototype = {
// We don't want to send typing messages to our other devices, but we will
// in the group case.
const myNumber = textsecure.storage.user.getNumber();
if (recipientId && myNumber === recipientId) {
const primaryDevicePubkey = window.storage.get('primaryDevicePubKey');
if (recipientId && primaryDevicePubkey === recipientId) {
return null;
}
@ -1163,6 +1203,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) {
this.sendRequestContactSyncMessage = sender.sendRequestContactSyncMessage.bind(
sender
);
this.sendContactSyncMessage = sender.sendContactSyncMessage.bind(sender);
this.sendRequestConfigurationSyncMessage = sender.sendRequestConfigurationSyncMessage.bind(
sender
);

@ -18,7 +18,7 @@ describe('ContactBuffer', () => {
const contactInfoBuffer = contactInfo.encode().toArrayBuffer();
for (let i = 0; i < 3; i += 1) {
buffer.writeVarint32(contactInfoBuffer.byteLength);
buffer.writeInt32(contactInfoBuffer.byteLength);
buffer.append(contactInfoBuffer);
buffer.append(avatarBuffer.clone());
}
@ -69,7 +69,7 @@ describe('GroupBuffer', () => {
const groupInfoBuffer = groupInfo.encode().toArrayBuffer();
for (let i = 0; i < 3; i += 1) {
buffer.writeVarint32(groupInfoBuffer.byteLength);
buffer.writeInt32(groupInfoBuffer.byteLength);
buffer.append(groupInfoBuffer);
buffer.append(avatarBuffer.clone());
}

@ -45,6 +45,7 @@ window.JobQueue = JobQueue;
window.getStoragePubKey = key =>
window.isDev() ? key.substring(0, key.length - 2) : key;
window.getDefaultFileServer = () => config.defaultFileServer;
window.initialisedAPI = false;
window.isBeforeVersion = (toCheck, baseVersion) => {
try {
@ -468,5 +469,6 @@ window.pubkeyPattern = /@[a-fA-F0-9]{64,66}\b/g;
window.SMALL_GROUP_SIZE_LIMIT = 10;
window.lokiFeatureFlags = {
privateGroupChats: true,
multiDeviceUnpairing: false,
privateGroupChats: false,
};

@ -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,13 @@ message LokiAddressMessage {
optional Type type = 3;
}
message PairingAuthorisationMessage {
optional string primaryDevicePubKey = 1;
optional string secondaryDevicePubKey = 2;
optional bytes requestSignature = 3;
optional bytes grantSignature = 4;
}
message PreKeyBundleMessage {
optional bytes identityKey = 1;
optional uint32 deviceId = 2;
@ -259,6 +267,7 @@ message SyncMessage {
message Contacts {
optional AttachmentPointer blob = 1;
optional bool complete = 2 [default = false];
optional bytes data = 101;
}
message Groups {
@ -354,6 +363,7 @@ message ContactDetails {
optional bytes profileKey = 6;
optional bool blocked = 7;
optional uint32 expireTimer = 8;
optional string nickname = 101;
}
message GroupDetails {

@ -723,6 +723,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 {

@ -41,6 +41,7 @@ export type PropsData = {
isOnline?: boolean;
hasNickname?: boolean;
isFriendItem?: boolean;
isSecondary?: boolean;
};
type PropsHousekeeping = {

@ -56,7 +56,15 @@ export class LeftPane extends React.Component<Props, any> {
const { conversations, friends } = this.props;
const { currentTab } = this.state;
return currentTab === 'conversations' ? conversations : friends;
let conversationList =
currentTab === 'conversations' ? conversations : friends;
if (conversationList !== undefined) {
conversationList = conversationList.filter(
conversation => !conversation.isSecondary
);
}
return conversationList;
}
public renderTabs(): JSX.Element {

@ -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';
@ -43,17 +44,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;
@ -112,18 +107,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,
});
}
}
@ -318,7 +324,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 = [
@ -389,6 +395,24 @@ export class MainHeader extends React.Component<Props, any> {
menuItems.push(passItem('set'));
}
if (!isSecondaryDevice) {
menuItems.push({
id: 'pairNewDevice',
name: 'Device Pairing',
onClick: () => {
trigger('showDevicePairingDialog');
},
});
} else {
menuItems.push({
id: 'showPairingWords',
name: 'Show Pairing Words',
onClick: () => {
trigger('showDevicePairingWordsDialog');
},
});
}
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