From d0c1a2bf3a0d1d3bf446dc70679bd84b9778d3e3 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 2 Jun 2021 12:29:06 +1000 Subject: [PATCH] add ONS resolve for new ONS and loading on message overlay --- _locales/en/messages.json | 25 ++-- _locales/fr/messages.json | 12 +- _locales/ru/messages.json | 11 -- js/modules/loki_snode_api.js | 121 ------------------ .../session/LeftPaneMessageSection.tsx | 43 +++++-- .../session/SessionClosableOverlay.tsx | 2 +- ts/global.d.ts | 10 -- ts/session/snode_api/SNodeAPI.ts | 116 +++++++++++++++-- ts/session/snode_api/lokiRpc.ts | 2 +- ts/session/utils/String.ts | 8 +- 10 files changed, 156 insertions(+), 194 deletions(-) delete mode 100644 js/modules/loki_snode_api.js delete mode 100644 ts/global.d.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7f42594d0..536472d64 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1143,10 +1143,14 @@ "description": "Label underneath number a user enters that is not an existing contact" }, "invalidNumberError": { - "message": "Invalid public key", - "description": "When a person inputs a public key that is invalid", + "message": "Invalid Session ID or ONS Name", + "description": "When a person inputs a session ID or an ons name that is invalid ", "androidKey": "fragment_new_conversation_invalid_public_key_message" }, + "failedResolveOns": { + "message": "Failed to resolve ONS name", + "description": "When a person inputs a an ons name that is not resolved " + }, "successUnlinked": { "message": "Your device was unlinked successfully", "androidKey": "activity_landing_device_unlinked_dialog_title" @@ -1603,17 +1607,7 @@ "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" - }, + "emptyGroupNameError": { "message": "Please enter a group name", "description": "Error message displayed on empty group name", @@ -1750,8 +1744,11 @@ "message": "Enter Session ID", "androidKey": "activity_link_device_enter_session_id_tab_title" }, + "enterSessionIDOrONSName": { + "message": "Enter Session ID or ONS name" + }, "enterSessionIDOfRecipient": { - "message": "Enter Session ID of recipient", + "message": "Enter Session ID or ONS name of recipient", "androidKey": "fragment_enter_public_key_edit_text_hint" }, "usersCanShareTheir...": { diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index e83c07447..278e68e19 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -1351,17 +1351,7 @@ "message": "Format de clé publique non valide", "description": "Error string shown when user types an invalid pubkey format" }, - "lnsMappingNotFound": { - "message": "Aucun mappage LNS n'est associé à ce nom", - "description": "Shown in toast if user enters an unknown LNS name" - }, - "lnsLookupTimeout": { - "message": "La recherche LNS a expiré", - "description": "Shown in toast if user enters an unknown LNS name" - }, - "lnsTooFewNodes": { - "message": "Il n'y a pas assez de nœuds actifs actuellement pour la recherche LNS" - }, + "editProfileModalTitle": { "message": "Profil", "description": "Title for the Edit Profile modal" diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 423da8102..4d5c6f148 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -1347,17 +1347,6 @@ "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" - }, "editProfileModalTitle": { "message": "Аккаунт", "description": "Title for the Edit Profile modal" diff --git a/js/modules/loki_snode_api.js b/js/modules/loki_snode_api.js deleted file mode 100644 index eb17104c1..000000000 --- a/js/modules/loki_snode_api.js +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable class-methods-use-this */ -/* global window, Buffer, StringView, dcodeIO */ - -class LokiSnodeAPI { - // ************** NOTE *************** - // This is not used by anything yet, - // but should be. Do not remove!!! - // *********************************** - 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 = await window.SnodePool.getNodesMinVersion( - window.CONSTANTS.LNS_CAPABLE_NODES_VERSION - ); - - // Enough nodes? - if (lnsNodes.length < numRequiredConfirms) { - error = { lnsTooFewNodes: window.i18n('lnsTooFewNodes') }; - return { pubkey, error }; - } - - const confirmedNodes = []; - - // Promise is only resolved when a consensus is found - let cipherResolve; - const cipherPromise = () => - new Promise(resolve => { - cipherResolve = resolve; - }); - - const decryptHex = async cipherHex => { - const ciphertext = new Uint8Array(StringView.hexToArrayBuffer(cipherHex)); - - const res = await window.decryptLnsEntry(lnsName, ciphertext); - const publicKey = StringView.arrayBufferToHex(res); - - return publicKey; - }; - - const fetchFromNode = async node => { - const res = await window.NewSnodeAPI._requestLnsMapping(node, nameHash); - - // 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 nodes = lnsNodes.splice(0, numRequests); - - // Start fetching from nodes - nodes.forEach(node => fetchFromNode(node)); - - // 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, error }; - } -} - -module.exports = LokiSnodeAPI; diff --git a/ts/components/session/LeftPaneMessageSection.tsx b/ts/components/session/LeftPaneMessageSection.tsx index fe7cd779a..173f40772 100644 --- a/ts/components/session/LeftPaneMessageSection.tsx +++ b/ts/components/session/LeftPaneMessageSection.tsx @@ -27,6 +27,8 @@ import { ConversationTypeEnum } from '../../models/conversation'; import { openGroupV2CompleteURLRegex } from '../../opengroup/utils/OpenGroupUtils'; import { joinOpenGroupV2WithUIEvents } from '../../opengroup/opengroupV2/JoinOpenGroupV2'; import autoBind from 'auto-bind'; +import { onsNameRegex } from '../../session/snode_api/SNodeAPI'; +import { SNodeAPI } from '../../session/snode_api'; export interface Props { searchTerm: string; @@ -276,6 +278,7 @@ export class LeftPaneMessageSection extends React.Component { onButtonClick={this.handleMessageButtonClick} searchTerm={searchTerm} searchResults={searchResults} + showSpinner={loading} updateSearch={this.updateSearch} theme={this.props.theme} /> @@ -339,23 +342,45 @@ export class LeftPaneMessageSection extends React.Component { const { openConversationExternal } = this.props; if (!this.state.valuePasted && !this.props.searchTerm) { - ToastUtils.pushToastError('invalidPubKey', window.i18n('invalidNumberError')); + ToastUtils.pushToastError('invalidPubKey', window.i18n('invalidNumberError')); // or ons name return; } - let pubkey: string; - pubkey = this.state.valuePasted || this.props.searchTerm; - pubkey = pubkey.trim(); + let pubkeyorOns: string; + pubkeyorOns = this.state.valuePasted || this.props.searchTerm; + pubkeyorOns = pubkeyorOns.trim(); - const error = PubKey.validateWithError(pubkey); - if (!error) { + const errorOnPubkey = PubKey.validateWithError(pubkeyorOns); + if (!errorOnPubkey) { + // this is a pubkey await ConversationController.getInstance().getOrCreateAndWait( - pubkey, + pubkeyorOns, ConversationTypeEnum.PRIVATE ); - openConversationExternal(pubkey); + openConversationExternal(pubkeyorOns); this.handleToggleOverlay(undefined); } else { - ToastUtils.pushToastError('invalidPubKey', error); + // this might be an ONS, validate the regex first + const mightBeOnsName = new RegExp(onsNameRegex, 'g').test(pubkeyorOns); + if (!mightBeOnsName) { + ToastUtils.pushToastError('invalidPubKey', window.i18n('invalidNumberError')); + return; + } + this.setState({ loading: true }); + try { + const resolvedSessionID = await SNodeAPI.getSessionIDForOnsName(pubkeyorOns); + // this is a pubkey + await ConversationController.getInstance().getOrCreateAndWait( + resolvedSessionID, + ConversationTypeEnum.PRIVATE + ); + openConversationExternal(resolvedSessionID); + this.handleToggleOverlay(undefined); + } catch (e) { + window?.log?.warn('failed to resolve ons name', pubkeyorOns, e); + ToastUtils.pushToastError('invalidPubKey', window.i18n('failedResolveOns')); + } finally { + this.setState({ loading: false }); + } } } diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index a44514390..8f4c7a1cc 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -129,7 +129,7 @@ export class SessionClosableOverlay extends React.Component { title = window.i18n('newSession'); buttonText = window.i18n('next'); descriptionLong = window.i18n('usersCanShareTheir...'); - subtitle = window.i18n('enterSessionID'); + subtitle = window.i18n('enterSessionIDOrONSName'); placeholder = window.i18n('enterSessionIDOfRecipient'); break; case 'open-group': diff --git a/ts/global.d.ts b/ts/global.d.ts deleted file mode 100644 index f8321457f..000000000 --- a/ts/global.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -interface Promise { - ignore(): void; -} - -// Types also correspond to messages.json keys -enum LnsLookupErrorType { - lnsTooFewNodes, - lnsLookupTimeout, - lnsMappingNotFound, -} diff --git a/ts/session/snode_api/SNodeAPI.ts b/ts/session/snode_api/SNodeAPI.ts index 177767af5..bfc6557fb 100644 --- a/ts/session/snode_api/SNodeAPI.ts +++ b/ts/session/snode_api/SNodeAPI.ts @@ -13,9 +13,20 @@ import { snodeRpc } from './lokiRpc'; import { getRandomSnode, getRandomSnodePool, requiredSnodesForAgreement, Snode } from './snodePool'; import { Constants } from '..'; -import { sha256 } from '../crypto'; -import _ from 'lodash'; +import { getSodium, sha256 } from '../crypto'; +import _, { range } from 'lodash'; import pRetry from 'p-retry'; +import { + fromHex, + fromHexToArray, + fromUInt8ArrayToBase64, + stringToUint8Array, + toHex, +} from '../utils/String'; + +// ONS name can have [a-zA-Z0-9_-] except that - is not allowed as start or end +// do not define a regex but rather create it on the fly to avoid https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time +export const onsNameRegex = '^[a-zA-Z0-9_][a-zA-Z0-9_-]*[a-zA-Z0-9_]$'; const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { let filePrefix = ''; @@ -247,20 +258,97 @@ export async function requestSnodesForPubkey(pubKey: string): Promise { + const targetNode = await getRandomSnode(); + const result = await snodeRpc('oxend_request', params, targetNode); + if (!result || result.status !== 200 || !result.body) { + throw new Error('ONSresolve:Failed to resolve ONS'); + } + let parsedBody; + try { + parsedBody = JSON.parse(result.body); + } catch (e) { + window?.log?.warn('ONSresolve: failed to parse ons result body', result.body); + throw new Error('ONSresolve: json ONS resovle'); + } + const intermediate = parsedBody?.result; + + if (!intermediate || !intermediate?.encrypted_value) { + throw new Error('ONSresolve: no encrypted_value'); + } + const hexEncodedCipherText = intermediate?.encrypted_value; + + const isArgon2Based = !Boolean(intermediate?.nonce); + const ciphertext = fromHexToArray(hexEncodedCipherText); + if (isArgon2Based) { + return ''; + } + + // not argon2Based + const hexEncodedNonce = intermediate.nonce as string; + if (!hexEncodedNonce) { + throw new Error('ONSresolve: No hexEncodedNonce'); + } + const nonce = fromHexToArray(hexEncodedNonce); + + let key; + try { + key = sodium.crypto_generichash(sodium.crypto_generichash_BYTES, nameAsData, nameHash); + if (!key) { + throw new Error('ONSresolve: Hashing failed'); + } + } catch (e) { + window?.log?.warn('ONSresolve: hashing failed', e); + throw new Error('ONSresolve: Hashing failed'); + } + + const sessionIDAsData = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + null, + ciphertext, + null, + nonce, + key ); + + if (!sessionIDAsData) { + throw new Error('ONSresolve: Decryption failed'); + } + + return toHex(sessionIDAsData); + }); + + try { + // if one promise throws, we end un the catch case + const allResolvedSessionIds = await Promise.all(promises); + if (allResolvedSessionIds?.length !== validationCount) { + throw new Error('ONSresolve: Validation failed'); + } + + // assert all the returned session ids are the same + if (_.uniq(allResolvedSessionIds).length !== 1) { + throw new Error('ONSresolve: Validation failed'); + } + return allResolvedSessionIds[0]; } catch (e) { - window?.log?.warn('exception caught making lns requests to a node', targetNode, e); - return false; + window.log.warn('ONSresolve: error', e); + throw e; } } diff --git a/ts/session/snode_api/lokiRpc.ts b/ts/session/snode_api/lokiRpc.ts index 64cdd3e1a..77a26b715 100644 --- a/ts/session/snode_api/lokiRpc.ts +++ b/ts/session/snode_api/lokiRpc.ts @@ -67,7 +67,7 @@ async function lokiFetch( * This function will throw for a few reasons. * The loki-important ones are * -> if we try to make a request to a path which fails too many times => user will need to retry himself - * -> if the targetNode gets too many errors => we will need to try do to this request again with anoter target node + * -> if the targetNode gets too many errors => we will need to try to do this request again with another target node * The */ export async function snodeRpc( diff --git a/ts/session/utils/String.ts b/ts/session/utils/String.ts index 7745921cd..90a7e669b 100644 --- a/ts/session/utils/String.ts +++ b/ts/session/utils/String.ts @@ -33,10 +33,10 @@ export function nonNullish(v: V): v is NonNullable { export const toHex = (d: BufferType) => decode(d, 'hex'); export const fromHex = (d: string) => encode(d, 'hex'); -export const fromHexToArray = (d: string) => new Uint8Array(encode(d, 'hex')); +export const fromHexToArray = (d: string) => new Uint8Array(fromHex(d)); export const fromBase64ToArrayBuffer = (d: string) => encode(d, 'base64'); -export const fromBase64ToArray = (d: string) => new Uint8Array(encode(d, 'base64')); +export const fromBase64ToArray = (d: string) => new Uint8Array(fromBase64ToArrayBuffer(d)); export const fromArrayBufferToBase64 = (d: BufferType) => decode(d, 'base64'); export const fromUInt8ArrayToBase64 = (d: Uint8Array) => decode(d, 'base64'); @@ -48,3 +48,7 @@ export const stringToArrayBuffer = (str: string): ArrayBuffer => { return encode(str, 'binary'); }; + +export const stringToUint8Array = (str: string): Uint8Array => { + return new Uint8Array(stringToArrayBuffer(str)); +};