diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d852ffd13..216832649 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -19,4 +19,4 @@ If applicable, add screenshots or log files to help explain your problem. * Device: [e.g. PC, Mac] * OS: [e.g. Ubuntu 16.04, Windows 10] -* Loki messenger Version or Git commit hash: +* Session Version or Git commit hash: diff --git a/README.md b/README.md index 6175fa925..766d7db52 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [![Build Status](https://travis-ci.org/loki-project/loki-messenger.svg?branch=development)](https://travis-ci.org/loki-project/loki-messenger) -Session allows for truly decentralized, end to end, and private encrypted chats. Session is built to handle both online and fully Asynchronous offline messages. Loki messenger implements the Signal protocol for message encryption. Our Client interface is a fork of [Signal Messenger](https://signal.org/). All communication that passes through Loki messenger is routed through [Lokinet](https://github.com/loki-project/loki-network). +Session allows for truly decentralized, end to end, and private encrypted chats. Session is built to handle both online and fully Asynchronous offline messages. Session implements the Signal protocol for message encryption. Our Client interface is a fork of [Signal Messenger](https://signal.org/). All communication that passes through Session is routed through [Lokinet](https://github.com/loki-project/loki-network). ## Summary -Loki messenger integrates directly with Loki [Service Nodes](https://lokidocs.com/ServiceNodes/SNOverview/), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as both federated servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users IP Addresses. For a full understanding of how Loki messenger works, read the [Loki whitepaper](https://loki.network/whitepaper). +Session integrates directly with Loki [Service Nodes](https://lokidocs.com/ServiceNodes/SNOverview/), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as both federated servers which store messages offline, and a set of nodes which allow for onion routing functionality obfuscating users IP Addresses. For a full understanding of how Session works, read the [Loki whitepaper](https://loki.network/whitepaper). **Online Messages** diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 139b7d0c5..35b03f94c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2226,7 +2226,7 @@ }, "setAccountPasswordDescription": { "message": - "Require password to unlock Session’s screen. You can still receive message notifications while Screen Lock is enabled. Loki Messenger’s notification settings allow you to customize information that is displayed", + "Require password to unlock Session’s screen. You can still receive message notifications while Screen Lock is enabled. Session’s notification settings allow you to customize information that is displayed", "description": "Description for set account password setting view" }, "changeAccountPasswordTitle": { diff --git a/js/background.js b/js/background.js index 04fdd2de8..9f6b99e35 100644 --- a/js/background.js +++ b/js/background.js @@ -1067,6 +1067,7 @@ const sslServerURL = `https://${rawserverURL}`; const conversationId = `publicChat:${channelId}@${rawserverURL}`; + // quickly peak to make sure we don't already have it const conversationExists = window.ConversationController.get( conversationId ); @@ -1077,9 +1078,11 @@ }); } + // get server const serverAPI = await window.lokiPublicChatAPI.findOrCreateServer( sslServerURL ); + // SSL certificate failure or offline if (!serverAPI) { // Url incorrect or server not compatible return new Promise((_resolve, reject) => { @@ -1087,16 +1090,20 @@ }); } + // create conversation const conversation = await window.ConversationController.getOrCreateAndWait( conversationId, 'group' ); + // convert conversation to a public one await conversation.setPublicSource(sslServerURL, channelId); + // set friend and appropriate SYNC messages for multidevice await conversation.setFriendRequestStatus( window.friends.friendRequestStatusEnum.friends ); + // and finally activate it conversation.getPublicSendData(); // may want "await" if you want to use the API return conversation; @@ -1966,7 +1973,10 @@ } const isDuplicate = await isMessageDuplicate(message); if (isDuplicate) { - window.log.warn('Received duplicate message', message.idForLogging()); + // RSS expects duplciates, so squelch log + if (!descriptorId.match(/^rss:/)) { + window.log.warn('Received duplicate message', message.idForLogging()); + } return event.confirm(); } diff --git a/js/expire.js b/js/expire.js index ece6938b4..32f9555fc 100644 --- a/js/expire.js +++ b/js/expire.js @@ -14,12 +14,19 @@ LokiFileServerAPI.secureRpcPubKey ); + let nextWaitSeconds = 1; const checkForUpgrades = async () => { - const response = await window.tokenlessFileServerAdnAPI.serverRequest( + const result = await window.tokenlessFileServerAdnAPI.serverRequest( 'loki/v1/version/client/desktop' ); - if (response && response.response) { - const latestVer = semver.clean(response.response.data[0][0]); + if ( + result && + result.response && + result.response.data && + result.response.data.length && + result.response.data[0].length + ) { + const latestVer = semver.clean(result.response.data[0][0]); if (semver.valid(latestVer)) { const ourVersion = window.getVersion(); if (latestVer === ourVersion) { @@ -36,10 +43,11 @@ } } else { // give it a minute - log.warn('Could not check to see if newer version is available'); + log.warn('Could not check to see if newer version is available', result); + nextWaitSeconds = 60; setTimeout(async () => { await checkForUpgrades(); - }, 60 * 1000); // wait a minute + }, nextWaitSeconds * 1000); // wait a minute } // no message logged means serverRequest never returned... }; @@ -56,22 +64,33 @@ if (expiredVersion !== null) { return res(expiredVersion); } - log.info('Delaying sending checks for 1s, no version yet'); - setTimeout(waitForVersion, 1000); + log.info( + 'Delaying sending checks for', + nextWaitSeconds, + 's, no version yet' + ); + setTimeout(waitForVersion, nextWaitSeconds * 1000); return true; } waitForVersion(); return true; }; + // just get current status + window.extension.expiredStatus = () => expiredVersion; + // actually wait until we know for sure window.extension.expiredPromise = () => new Promise(resolveWhenReady); window.extension.expired = cb => { if (expiredVersion === null) { // just give it another second - log.info('Delaying expire banner determination for 1s'); + log.info( + 'Delaying expire banner determination for', + nextWaitSeconds, + 's' + ); setTimeout(() => { window.extension.expired(cb); - }, 1000); + }, nextWaitSeconds * 1000); return; } // yes we know diff --git a/js/models/conversations.js b/js/models/conversations.js index 52290e7a9..29c9ab317 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -561,6 +561,7 @@ type: this.isPrivate() ? 'direct' : 'group', isMe: this.isMe(), isPublic: this.isPublic(), + isRss: this.isRss(), isClosable: this.isClosable(), isTyping: typingKeys.length > 0, lastUpdated: this.get('timestamp'), @@ -2798,11 +2799,19 @@ this.messageCollection.reset([]); - this.set({ - lastMessage: null, - timestamp: null, - active_at: null, - }); + // let's try to keep the RSS conversation open just empty... + if (this.isRss()) { + this.set({ + lastMessage: null, + }); + } else { + // this will remove the conversation from conversation lists... + this.set({ + lastMessage: null, + timestamp: null, + active_at: null, + }); + } // Reset our friend status if we're not friends if (!this.isFriend()) { diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index 752d37d93..7f2a5dc56 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -185,6 +185,15 @@ class LokiAppDotNetServerAPI { } this.token = token; + // if no token to verify, just bail now + if (!token) { + // + if (!forceRefresh) { + token = await this.getOrRefreshServerToken(true); + } + return token; + } + // verify token info const tokenRes = await this.serverRequest('token'); // if no problems and we have data @@ -270,7 +279,31 @@ class LokiAppDotNetServerAPI { res = await this.proxyFetch(url); } catch (e) { - log.error('requestToken request failed', e); + // should we retry here? + // no, this is the low level function + // not really an error, from a client's pov, network servers can fail... + if (e.code === 'ECONNREFUSED') { + // down + log.warn( + 'requestToken request can not connect', + this.baseServerUrl, + e.message + ); + } else if (e.code === 'ECONNRESET') { + // got disconnected + log.warn( + 'requestToken request lost connection', + this.baseServerUrl, + e.message + ); + } else { + log.error( + 'requestToken request failed', + this.baseServerUrl, + e.code, + e.message + ); + } return null; } if (!res.ok) { @@ -302,14 +335,17 @@ class LokiAppDotNetServerAPI { ); return res.ok; } catch (e) { + log.error('submitToken proxyFetch failure', e.code, e.message); return false; } } - async proxyFetch(urlObj, fetchOptions) { + async proxyFetch(urlObj, fetchOptions = { method: 'GET' }) { if ( window.lokiFeatureFlags.useSnodeProxy && (this.baseServerUrl === 'https://file-dev.lokinet.org' || + this.baseServerUrl === 'https://file.lokinet.org' || + this.baseServerUrl === 'https://file-dev.getsession.org' || this.baseServerUrl === 'https://file.getsession.org') ) { const finalOptions = { ...fetchOptions }; @@ -408,7 +444,21 @@ class LokiAppDotNetServerAPI { const result = await nodeFetch(url, firstHopOptions); const txtResponse = await result.text(); - let response = JSON.parse(txtResponse); + if (txtResponse === 'Service node is not ready: not in any swarm; \n') { + // mark snode bad + log.warn('Marking random snode bad', randSnode); + lokiSnodeAPI.markRandomNodeUnreachable(randSnode); + // retry (hopefully with new snode) + // FIXME: max number of retries... + return this._sendToProxy(endpoint, fetchOptions); + } + + let response = {}; + try { + response = JSON.parse(txtResponse); + } catch (e) { + log.warn(`_sendToProxy Could not parse outer JSON [${txtResponse}]`); + } if (response.meta && response.meta.code === 200) { // convert base64 in response to binary @@ -423,9 +473,17 @@ class LokiAppDotNetServerAPI { const textDecoder = new TextDecoder(); const json = textDecoder.decode(decrypted); // replace response - response = JSON.parse(json); + try { + response = JSON.parse(json); + } catch (e) { + log.warn(`_sendToProxy Could not parse inner JSON [${json}]`); + } } else { - log.warn('file server secure_rpc gave an non-200 response'); + log.warn( + 'file server secure_rpc gave an non-200 response: ', + response, + ` txtResponse[${txtResponse}]` + ); } return { result, txtResponse, response }; } @@ -469,7 +527,7 @@ class LokiAppDotNetServerAPI { fetchOptions.agent = snodeHttpsAgent; } } catch (e) { - log.info('serverRequest set up error:', JSON.stringify(e)); + log.info('serverRequest set up error:', e.code, e.message); return { err: e, }; @@ -483,6 +541,8 @@ class LokiAppDotNetServerAPI { if ( window.lokiFeatureFlags.useSnodeProxy && (this.baseServerUrl === 'https://file-dev.lokinet.org' || + this.baseServerUrl === 'https://file.lokinet.org' || + this.baseServerUrl === 'https://file-dev.getsession.org' || this.baseServerUrl === 'https://file.getsession.org') ) { mode = '_sendToProxy'; @@ -902,7 +962,11 @@ class LokiPublicChannelAPI { try { await this.pollOnceForModerators(); } catch (e) { - log.warn(`Error while polling for public chat moderators: ${e}`); + log.warn( + 'Error while polling for public chat moderators:', + e.code, + e.message + ); } if (this.running) { this.timers.moderator = setTimeout(() => { @@ -1052,7 +1116,11 @@ class LokiPublicChannelAPI { try { await this.pollForChannelOnce(); } catch (e) { - log.warn(`Error while polling for public chat room details: ${e}`); + log.warn( + 'Error while polling for public chat room details', + e.code, + e.message + ); } if (this.running) { this.timers.channel = setTimeout(() => { @@ -1103,7 +1171,11 @@ class LokiPublicChannelAPI { try { await this.pollOnceForDeletions(); } catch (e) { - log.warn(`Error while polling for public chat deletions: ${e}`); + log.warn( + 'Error while polling for public chat deletions:', + e.code, + e.message + ); } if (this.running) { this.timers.delete = setTimeout(() => { @@ -1278,7 +1350,11 @@ class LokiPublicChannelAPI { try { await this.pollOnceForMessages(); } catch (e) { - log.warn(`Error while polling for public chat messages: ${e}`); + log.warn( + 'Error while polling for public chat messages:', + e.code, + e.message + ); } if (this.running) { this.timers.message = setTimeout(() => { diff --git a/js/modules/loki_message_api.js b/js/modules/loki_message_api.js index b581a16ff..9ff394f41 100644 --- a/js/modules/loki_message_api.js +++ b/js/modules/loki_message_api.js @@ -288,7 +288,7 @@ class LokiMessageAPI { // Execute callback even with empty array to signal online status callback(messages); } catch (e) { - log.warn('Loki retrieve messages:', e); + log.warn('Loki retrieve messages:', e.code, e.message); if (e instanceof textsecure.WrongSwarmError) { const { newSwarm } = e; await lokiSnodeAPI.updateSwarmNodes(this.ourKey, newSwarm); @@ -352,7 +352,6 @@ class LokiMessageAPI { const lastHash = await window.Signal.Data.getLastHashBySnode( nodes[i].address ); - this.ourSwarmNodes[nodes[i].address] = { ...nodes[i], lastHash, diff --git a/js/modules/loki_public_chat_api.js b/js/modules/loki_public_chat_api.js index 20f68a17d..8e4cd0f3d 100644 --- a/js/modules/loki_public_chat_api.js +++ b/js/modules/loki_public_chat_api.js @@ -1,4 +1,4 @@ -/* global log, window */ +/* global log, window, process */ const EventEmitter = require('events'); const nodeFetch = require('node-fetch'); const LokiAppDotNetAPI = require('./loki_app_dot_net_api'); @@ -17,6 +17,26 @@ class LokiPublicChatFactoryAPI extends EventEmitter { await Promise.all(this.servers.map(server => server.close())); } + static async validServer(serverUrl) { + // test to make sure it's online (and maybe has a valid SSL cert) + try { + // allow .loki (may only need an agent but not sure + // until we have a .loki to test with) + process.env.NODE_TLS_REJECT_UNAUTHORIZED = serverUrl.match(/\.loki\//) + ? 0 + : 1; + await nodeFetch(serverUrl); + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; + // const txt = await res.text(); + } catch (e) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = 1; + log.warn(`failing to created ${serverUrl}`, e.code, e.message); + // bail out if not valid enough + return false; + } + return true; + } + // server getter/factory async findOrCreateServer(serverUrl) { let thisServer = this.servers.find( @@ -24,7 +44,14 @@ class LokiPublicChatFactoryAPI extends EventEmitter { ); if (!thisServer) { log.info(`LokiAppDotNetAPI creating ${serverUrl}`); + + if (!await this.constructor.validServer(serverUrl)) { + return null; + } + + // after verification then we can start up all the pollers thisServer = new LokiAppDotNetAPI(this.ourKey, serverUrl); + const gotToken = await thisServer.getOrRefreshServerToken(); if (!gotToken) { log.warn(`Invalid server ${serverUrl}`); diff --git a/js/modules/loki_rpc.js b/js/modules/loki_rpc.js index d725f04ed..1cd99b9c7 100644 --- a/js/modules/loki_rpc.js +++ b/js/modules/loki_rpc.js @@ -13,16 +13,18 @@ const LOKI_EPHEMKEY_HEADER = 'X-Loki-EphemKey'; const endpointBase = '/storage_rpc/v1'; const decryptResponse = async (response, address) => { + let plaintext = false; try { const ciphertext = await response.text(); - const plaintext = await libloki.crypto.snodeCipher.decrypt( - address, - ciphertext - ); + plaintext = await libloki.crypto.snodeCipher.decrypt(address, ciphertext); const result = plaintext === '' ? {} : JSON.parse(plaintext); return result; } catch (e) { - log.warn(`Could not decrypt response from ${address}`, e); + log.warn( + `Could not decrypt response [${plaintext}] from [${address}],`, + e.code, + e.message + ); } return {}; }; @@ -33,12 +35,6 @@ const sendToProxy = async (options = {}, targetNode) => { const url = `https://${randSnode.ip}:${randSnode.port}/proxy`; - log.info( - `Proxy snode request to ${targetNode.pubkey_ed25519} via ${ - randSnode.pubkey_ed25519 - }` - ); - const snPubkeyHex = StringView.hexToArrayBuffer(targetNode.pubkey_x25519); const myKeys = window.libloki.crypto.snodeCipher._ephemeralKeyPair; @@ -86,11 +82,21 @@ const sendToProxy = async (options = {}, targetNode) => { const textDecoder = new TextDecoder(); const plaintext = textDecoder.decode(plaintextBuffer); - const jsonRes = JSON.parse(plaintext); - - jsonRes.json = () => JSON.parse(jsonRes.body); - - return jsonRes; + try { + const jsonRes = JSON.parse(plaintext); + // emulate nodeFetch response... + jsonRes.json = () => JSON.parse(jsonRes.body); + return jsonRes; + } catch (e) { + log.error( + 'lokiRpc sendToProxy error', + e.code, + e.message, + 'json', + plaintext + ); + } + return false; }; // A small wrapper around node-fetch which deserializes response diff --git a/js/modules/loki_snode_api.js b/js/modules/loki_snode_api.js index 14a536e53..eb6bdc2f5 100644 --- a/js/modules/loki_snode_api.js +++ b/js/modules/loki_snode_api.js @@ -9,8 +9,8 @@ class LokiSnodeAPI { if (!is.string(serverUrl)) { throw new Error('WebAPI.initialize: Invalid server url'); } - this.serverUrl = serverUrl; - this.localUrl = localUrl; + this.serverUrl = serverUrl; // random.snode + this.localUrl = localUrl; // localhost.loki this.randomSnodePool = []; this.swarmsPendingReplenish = {}; } @@ -63,7 +63,7 @@ class LokiSnodeAPI { pubkey_ed25519: snode.pubkey_ed25519, })); } catch (e) { - log.warn('initialiseRandomPool error', JSON.stringify(e)); + log.warn('initialiseRandomPool error', e.code, e.message); if (seedNodes.length === 0) { throw new window.textsecure.SeedNodeError( 'Failed to contact seed node' @@ -73,6 +73,7 @@ class LokiSnodeAPI { } } + // nodeUrl is like 9hrje1bymy7hu6nmtjme9idyu3rm8gr3mkstakjyuw1997t7w4ny.snode async unreachableNode(pubKey, nodeUrl) { const conversation = ConversationController.get(pubKey); const swarmNodes = [...conversation.get('swarmNodes')]; @@ -82,6 +83,13 @@ class LokiSnodeAPI { await conversation.updateSwarmNodes(filteredNodes); } + markRandomNodeUnreachable(snode) { + this.randomSnodePool = _.without( + this.randomSnodePool, + _.find(this.randomSnodePool, { ip: snode.ip, port: snode.port }) + ); + } + async updateLastHash(snode, hash, expiresAt) { await window.Signal.Data.updateLastHash({ snode, hash, expiresAt }); } @@ -151,12 +159,8 @@ class LokiSnodeAPI { const snodes = result.snodes.filter(tSnode => tSnode.ip !== '0.0.0.0'); return snodes; } catch (e) { - log.error('getSwarmNodes', JSON.stringify(e)); - // - this.randomSnodePool = _.without( - this.randomSnodePool, - _.find(this.randomSnodePool, { ip: snode.ip }) - ); + log.error('getSwarmNodes error', e.code, e.message); + this.markRandomNodeUnreachable(snode); return this.getSwarmNodes(pubKey); } } diff --git a/js/views/connecting_to_server_dialog_view.js b/js/views/connecting_to_server_dialog_view.js index 2a491ff29..b2d800f1b 100644 --- a/js/views/connecting_to_server_dialog_view.js +++ b/js/views/connecting_to_server_dialog_view.js @@ -1,4 +1,4 @@ -/* global Whisper, i18n, ConversationController, friends */ +/* global Whisper, i18n, log */ // eslint-disable-next-line func-names (function() { @@ -24,31 +24,13 @@ 'click .cancel': 'close', }, async attemptConnection(serverUrl, channelId) { - const rawServerUrl = serverUrl - .replace(/^https?:\/\//i, '') - .replace(/[/\\]+$/i, ''); - const sslServerUrl = `https://${rawServerUrl}`; - const conversationId = `publicChat:${channelId}@${rawServerUrl}`; - - const conversationExists = ConversationController.get(conversationId); - if (conversationExists) { - // We are already a member of this public chat - return this.resolveWith({ errorCode: i18n('publicChatExists') }); + let conversation = null; + try { + conversation = await window.attemptConnection(serverUrl, channelId); + } catch (e) { + log.error('can not connect', e.message, e.code); + return this.resolveWith({ errorCode: e.message }); } - - // create conversation - const conversation = await ConversationController.getOrCreateAndWait( - conversationId, - 'group' - ); - // convert conversation to a public one - await conversation.setPublicSource(sslServerUrl, channelId); - // set friend and appropriate SYNC messages for multidevice - await conversation.setFriendRequestStatus( - friends.friendRequestStatusEnum.friends - ); - // and finally activate it - conversation.getPublicSendData(); // may want "await" if you want to use the API return this.resolveWith({ conversation }); }, resolveWith(result) { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index ab9e5a547..2e1fa720e 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1860,8 +1860,8 @@ message = window.Signal.Emoji.replaceColons(message).trim(); const toastOptions = { type: 'info' }; - const expiredVersion = await extension.expiredPromise(); - if (expiredVersion) { + // let it pass if we're still trying to read it or it's false... + if (extension.expiredStatus() === true) { toastOptions.title = i18n('expiredWarning'); toastOptions.id = 'expiredWarning'; } diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 8827853a4..0a3868527 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -23,6 +23,7 @@ export type PropsData = { avatarPath?: string; isMe: boolean; isPublic?: boolean; + isRss?: boolean; isClosable?: boolean; lastUpdated: number; @@ -176,6 +177,7 @@ export class ConversationListItem extends React.PureComponent { isBlocked, isMe, isClosable, + isRss, isPublic, hasNickname, onDeleteContact, @@ -192,18 +194,18 @@ export class ConversationListItem extends React.PureComponent { return ( - {!isPublic && !isMe ? ( + {!isPublic && !isRss && !isMe ? ( {blockTitle} ) : null} - {!isPublic && !isMe ? ( + {!isPublic && !isRss && !isMe ? ( {i18n('changeNickname')} ) : null} - {!isPublic && !isMe && hasNickname ? ( + {!isPublic && !isRss && !isMe && hasNickname ? ( {i18n('clearNickname')} ) : null} - {!isPublic ? ( + {!isPublic && !isRss ? ( {i18n('copyPublicKey')} ) : null} {i18n('deleteMessages')} diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a2dc56545..a25367cc5 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -319,7 +319,9 @@ export class ConversationHeader extends React.Component { return ( {this.renderPublicMenuItems()} - {copyIdLabel} + {!isRss ? ( + {copyIdLabel} + ) : null} {i18n('deleteMessages')} {isPrivateGroup || amMod ? ( {i18n('updateGroup')} @@ -477,7 +479,8 @@ export class ConversationHeader extends React.Component { {i18n('resetSession')} ); const blockHandlerMenuItem = !isMe && - !isGroup && {blockTitle}; + !isGroup && + !isRss && {blockTitle}; const changeNicknameMenuItem = !isMe && !isGroup && ( {i18n('changeNickname')}