diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c37ea1854..139bc81a9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2534,14 +2534,29 @@ "message": "Remove" }, "invalidHexId": { - "message": "Invalid Hex ID", + "message": "Invalid Session ID or LNS Name", "description": - "Error string shown when user type an invalid pubkey hex string" + "Error string shown when user types an invalid pubkey hex string" + }, + "invalidLnsFormat": { + "message": "Invalid LNS Name", + "description": "Error string shown when user types an invalid LNS name" }, "invalidPubkeyFormat": { "message": "Invalid Pubkey Format", "description": "Error string shown when user types an invalid pubkey format" }, + "lnsMappingNotFound": { + "message": "There is no LNS mapping associated with this name", + "description": "Shown in toast if user enters an unknown LNS name" + }, + "lnsLookupTimeout": { + "message": "LNS lookup timed out", + "description": "Shown in toast if user enters an unknown LNS name" + }, + "lnsTooFewNodes": { + "message": "Not enough nodes currently active for LNS lookup" + }, "conversationsTab": { "message": "Conversations", "description": "conversation tab title" @@ -2724,7 +2739,7 @@ "message": "Enter Session ID" }, "pasteSessionIDRecipient": { - "message": "Enter a Session ID" + "message": "Enter a Session ID or LNS name" }, "usersCanShareTheir...": { "message": diff --git a/js/modules/loki_snode_api.js b/js/modules/loki_snode_api.js index d6d030493..9d75df819 100644 --- a/js/modules/loki_snode_api.js +++ b/js/modules/loki_snode_api.js @@ -858,73 +858,122 @@ class LokiSnodeAPI { } } - async getLnsMapping(lnsName) { + async getLnsMapping(lnsName, timeout) { + // Returns { pubkey, error } + // pubkey is + // undefined when unconfirmed or no mapping found + // string when found + // timeout parameter optional (ms) + + // How many nodes to fetch data from? + const numRequests = 5; + + // How many nodes must have the same response value? + const numRequiredConfirms = 3; + + let ciphertextHex; + let pubkey; + let error; + const _ = window.Lodash; const input = Buffer.from(lnsName); - const output = await window.blake2b(input); - const nameHash = dcodeIO.ByteBuffer.wrap(output).toString('base64'); + // Timeouts + const maxTimeoutVal = 2 ** 31 - 1; + const timeoutPromise = () => + new Promise((_resolve, reject) => + setTimeout(() => reject(), timeout || maxTimeoutVal) + ); + // Get nodes capable of doing LNS - const lnsNodes = this.getNodesMinVersion('2.0.3'); - // randomPool should already be shuffled - // lnsNodes = _.shuffle(lnsNodes); + const lnsNodes = await this.getNodesMinVersion( + window.CONSTANTS.LNS_CAPABLE_NODES_VERSION + ); - // Loop until 3 confirmations + // Enough nodes? + if (lnsNodes.length < numRequiredConfirms) { + error = { lnsTooFewNodes: window.i18n('lnsTooFewNodes') }; + return { pubkey, error }; + } - // We don't trust any single node, so we accumulate - // answers here and select a dominating answer - const allResults = []; - let ciphertextHex = null; + const confirmedNodes = []; - while (!ciphertextHex) { - if (lnsNodes.length < 3) { - log.error('Not enough nodes for lns lookup'); - return false; - } + // Promise is only resolved when a consensus is found + let cipherResolve; + const cipherPromise = () => + new Promise(resolve => { + cipherResolve = resolve; + }); - // extract 3 and make requests in parallel - const nodes = lnsNodes.splice(0, 3); + const decryptHex = async cipherHex => { + const ciphertext = new Uint8Array(StringView.hexToArrayBuffer(cipherHex)); - // eslint-disable-next-line no-await-in-loop - const results = await Promise.all( - nodes.map(node => this._requestLnsMapping(node, nameHash)) - ); + const res = await window.decryptLnsEntry(lnsName, ciphertext); + const publicKey = StringView.arrayBufferToHex(res); - results.forEach(res => { - if ( - res && - res.result && - res.result.status === 'OK' && - res.result.entries && - res.result.entries.length > 0 - ) { - allResults.push(results[0].result.entries[0].encrypted_value); - } - }); + return publicKey; + }; - const [winner, count] = _.maxBy( - _.entries(_.countBy(allResults)), - x => x[1] - ); + const fetchFromNode = async node => { + const res = await this._requestLnsMapping(node, nameHash); - if (count >= 3) { - // eslint-disable-next-lint prefer-destructuring - ciphertextHex = winner; + // Do validation + if (res && res.result && res.result.status === 'OK') { + const hasMapping = res.result.entries && res.result.entries.length > 0; + + const resValue = hasMapping + ? res.result.entries[0].encrypted_value + : null; + + confirmedNodes.push(resValue); + + if (confirmedNodes.length >= numRequiredConfirms) { + if (ciphertextHex) { + // Result already found, dont worry + return; + } + + const [winner, count] = _.maxBy( + _.entries(_.countBy(confirmedNodes)), + x => x[1] + ); + + if (count >= numRequiredConfirms) { + ciphertextHex = winner === String(null) ? null : winner; + + // null represents no LNS mapping + if (ciphertextHex === null) { + error = { lnsMappingNotFound: window.i18n('lnsMappingNotFound') }; + } + + cipherResolve({ ciphertextHex }); + } + } } - } + }; - const ciphertext = new Uint8Array( - StringView.hexToArrayBuffer(ciphertextHex) - ); + const nodes = lnsNodes.splice(0, numRequests); - const res = await window.decryptLnsEntry(lnsName, ciphertext); + // Start fetching from nodes + nodes.forEach(node => fetchFromNode(node)); - const pubkey = StringView.arrayBufferToHex(res); + // Timeouts (optional parameter) + // Wait for cipher to be found; race against timeout + // eslint-disable-next-line more/no-then + await Promise.race([cipherPromise, timeoutPromise].map(f => f())) + .then(async () => { + if (ciphertextHex !== null) { + pubkey = await decryptHex(ciphertextHex); + } + }) + .catch(() => { + error = { lnsLookupTimeout: window.i18n('lnsLookupTimeout') }; + }); - return pubkey; + return { pubkey, error }; } // get snodes for pubkey from random snode diff --git a/preload.js b/preload.js index 301537a5d..1f485dd20 100644 --- a/preload.js +++ b/preload.js @@ -70,18 +70,31 @@ window.isBeforeVersion = (toCheck, baseVersion) => { } }; -window.CONSTANTS = { - MAX_LOGIN_TRIES: 3, - MAX_PASSWORD_LENGTH: 64, - MAX_USERNAME_LENGTH: 20, - MAX_GROUP_NAME_LENGTH: 64, - DEFAULT_PUBLIC_CHAT_URL: appConfig.get('defaultPublicChatServer'), - MAX_CONNECTION_DURATION: 5000, - MAX_MESSAGE_BODY_LENGTH: 64 * 1024, +// eslint-disable-next-line func-names +window.CONSTANTS = new function() { + this.MAX_LOGIN_TRIES = 3; + this.MAX_PASSWORD_LENGTH = 64; + this.MAX_USERNAME_LENGTH = 20; + this.MAX_GROUP_NAME_LENGTH = 64; + this.DEFAULT_PUBLIC_CHAT_URL = appConfig.get('defaultPublicChatServer'); + this.MAX_CONNECTION_DURATION = 5000; + this.MAX_MESSAGE_BODY_LENGTH = 64 * 1024; // Limited due to the proof-of-work requirement - SMALL_GROUP_SIZE_LIMIT: 10, - NOTIFICATION_ENABLE_TIMEOUT_SECONDS: 10, // number of seconds to turn on notifications after reconnect/start of app -}; + this.SMALL_GROUP_SIZE_LIMIT = 10; + // Number of seconds to turn on notifications after reconnect/start of app + this.NOTIFICATION_ENABLE_TIMEOUT_SECONDS = 10; + this.SESSION_ID_LENGTH = 66; + + // Loki Name System (LNS) + this.LNS_DEFAULT_LOOKUP_TIMEOUT = 6000; + // Minimum nodes version for LNS lookup + this.LNS_CAPABLE_NODES_VERSION = '2.0.3'; + this.LNS_MAX_LENGTH = 64; + // Conforms to naming rules here + // https://loki.network/2020/03/25/loki-name-system-the-facts/ + this.LNS_REGEX = `^[a-zA-Z0-9_]([a-zA-Z0-9_-]{0,${this.LNS_MAX_LENGTH - + 2}}[a-zA-Z0-9_]){0,1}$`; +}(); window.versionInfo = { environment: window.getEnvironment(), diff --git a/ts/global.d.ts b/ts/global.d.ts index 34564325a..a718447d2 100644 --- a/ts/global.d.ts +++ b/ts/global.d.ts @@ -63,3 +63,10 @@ interface Window { interface Promise { ignore(): void; } + +// Types also correspond to messages.json keys +enum LnsLookupErrorType { + lnsTooFewNodes, + lnsLookupTimeout, + lnsMappingNotFound, +}