add ONS resolve for new ONS and loading on message overlay

This commit is contained in:
Audric Ackermann 2021-06-02 12:29:06 +10:00
parent 61b04929f0
commit d0c1a2bf3a
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4
10 changed files with 156 additions and 194 deletions

View File

@ -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...": {

View File

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

View File

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

View File

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

View File

@ -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<Props, State> {
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<Props, State> {
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 });
}
}
}

View File

@ -129,7 +129,7 @@ export class SessionClosableOverlay extends React.Component<Props, State> {
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':

10
ts/global.d.ts vendored
View File

@ -1,10 +0,0 @@
interface Promise<T> {
ignore(): void;
}
// Types also correspond to messages.json keys
enum LnsLookupErrorType {
lnsTooFewNodes,
lnsLookupTimeout,
lnsMappingNotFound,
}

View File

@ -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<Array<Snod
}
}
export async function requestLnsMapping(targetNode: Snode, nameHash: any) {
window?.log?.debug('[lns] lns requests to {}:{}', targetNode.ip, targetNode);
try {
// TODO: Check response status
return snodeRpc(
'get_lns_mapping',
{
name_hash: nameHash,
},
targetNode
export async function getSessionIDForOnsName(onsNameCase: string) {
const validationCount = 3;
const onsNameLowerCase = onsNameCase.toLowerCase();
const sodium = await getSodium();
const nameAsData = stringToUint8Array(onsNameLowerCase);
const nameHash = sodium.crypto_generichash(sodium.crypto_generichash_BYTES, nameAsData);
const base64EncodedNameHash = fromUInt8ArrayToBase64(nameHash);
const params = {
endpoint: 'ons_resolve',
params: {
type: 0,
name_hash: base64EncodedNameHash,
},
};
// we do this request with validationCount snodes
const promises = range(0, validationCount).map(async () => {
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;
}
}

View File

@ -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(

View File

@ -33,10 +33,10 @@ export function nonNullish<V>(v: V): v is NonNullable<V> {
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));
};