add ONS resolve for new ONS and loading on message overlay
This commit is contained in:
parent
61b04929f0
commit
d0c1a2bf3a
|
@ -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...": {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
interface Promise<T> {
|
||||
ignore(): void;
|
||||
}
|
||||
|
||||
// Types also correspond to messages.json keys
|
||||
enum LnsLookupErrorType {
|
||||
lnsTooFewNodes,
|
||||
lnsLookupTimeout,
|
||||
lnsMappingNotFound,
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue