You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			544 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			544 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
| import { default as insecureNodeFetch, Response } from 'node-fetch';
 | |
| import https from 'https';
 | |
| 
 | |
| import { Snode } from './snodePool';
 | |
| import ByteBuffer from 'bytebuffer';
 | |
| import { OnionPaths } from '../onions';
 | |
| import { fromBase64ToArrayBuffer, toHex } from '../utils/String';
 | |
| 
 | |
| export enum RequestError {
 | |
|   BAD_PATH = 'BAD_PATH',
 | |
|   OTHER = 'OTHER',
 | |
|   ABORTED = 'ABORTED',
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * When sending a request over onion, we might get two status.
 | |
|  * The first one, on the request itself, the other one in the json returned.
 | |
|  *
 | |
|  * If the request failed to reach the one of the node of the onion path, the one on the request is set.
 | |
|  * But if the request reaches the destination node and it fails to process the request (bad node for this pubkey), you will get a 200 on the request itself, but the json you get will contain the real status.
 | |
|  */
 | |
| export interface SnodeResponse {
 | |
|   body: string;
 | |
|   status: number;
 | |
| }
 | |
| 
 | |
| // Returns the actual ciphertext, symmetric key that will be used
 | |
| // for decryption, and an ephemeral_key to send to the next hop
 | |
| async function encryptForPubKey(pubKeyX25519hex: string, reqObj: any): Promise<DestinationContext> {
 | |
|   const reqStr = JSON.stringify(reqObj);
 | |
| 
 | |
|   const textEncoder = new TextEncoder();
 | |
|   const plaintext = textEncoder.encode(reqStr);
 | |
| 
 | |
|   return window.libloki.crypto.encryptForPubkey(pubKeyX25519hex, plaintext);
 | |
| }
 | |
| 
 | |
| export type DestinationRelayV2 = {
 | |
|   host?: string;
 | |
|   protocol?: string;
 | |
|   port?: number;
 | |
|   destination?: string;
 | |
|   method?: string;
 | |
|   target?: string;
 | |
| };
 | |
| 
 | |
| // `ctx` holds info used by `node` to relay further
 | |
| async function encryptForRelayV2(
 | |
|   relayX25519hex: string,
 | |
|   destination: DestinationRelayV2,
 | |
|   ctx: DestinationContext
 | |
| ) {
 | |
|   const { log } = window;
 | |
| 
 | |
|   if (!destination.host && !destination.destination) {
 | |
|     log.warn('loki_rpc::encryptForRelayV2 - no destination', destination);
 | |
|   }
 | |
| 
 | |
|   const reqObj = {
 | |
|     ...destination,
 | |
|     ephemeral_key: toHex(ctx.ephemeralKey),
 | |
|   };
 | |
| 
 | |
|   const plaintext = encodeCiphertextPlusJson(ctx.ciphertext, reqObj);
 | |
| 
 | |
|   return window.libloki.crypto.encryptForPubkey(relayX25519hex, plaintext);
 | |
| }
 | |
| 
 | |
| /// Encode ciphertext as (len || binary) and append payloadJson as utf8
 | |
| function encodeCiphertextPlusJson(
 | |
|   ciphertext: Uint8Array,
 | |
|   payloadJson: Record<string, any>
 | |
| ): Uint8Array {
 | |
|   const payloadStr = JSON.stringify(payloadJson);
 | |
| 
 | |
|   const bufferJson = ByteBuffer.wrap(payloadStr, 'utf8');
 | |
| 
 | |
|   const len = ciphertext.length;
 | |
|   const arrayLen = bufferJson.buffer.length + 4 + len;
 | |
|   const littleEndian = true;
 | |
|   const buffer = new ByteBuffer(arrayLen, littleEndian);
 | |
| 
 | |
|   buffer.writeInt32(len);
 | |
|   buffer.append(ciphertext);
 | |
|   buffer.append(bufferJson);
 | |
| 
 | |
|   return new Uint8Array(buffer.buffer);
 | |
| }
 | |
| 
 | |
| async function buildOnionCtxs(
 | |
|   nodePath: Array<Snode>,
 | |
|   destCtx: DestinationContext,
 | |
|   targetED25519Hex?: string,
 | |
|   finalRelayOptions?: FinalRelayOptions,
 | |
|   id = ''
 | |
| ) {
 | |
|   const { log } = window;
 | |
| 
 | |
|   const ctxes = [destCtx];
 | |
|   // from (3) 2 to 0
 | |
|   const firstPos = nodePath.length - 1;
 | |
| 
 | |
|   for (let i = firstPos; i > -1; i -= 1) {
 | |
|     let dest: DestinationRelayV2;
 | |
|     const relayingToFinalDestination = i === firstPos; // if last position
 | |
| 
 | |
|     if (relayingToFinalDestination && finalRelayOptions) {
 | |
|       let target = '/loki/v2/lsrpc';
 | |
| 
 | |
|       const isCallToPn = finalRelayOptions?.host === 'live.apns.getsession.org';
 | |
|       if (!isCallToPn && window.lokiFeatureFlags.useFileOnionRequestsV2) {
 | |
|         target = '/loki/v3/lsrpc';
 | |
|       }
 | |
| 
 | |
|       dest = {
 | |
|         host: finalRelayOptions.host,
 | |
|         target,
 | |
|         method: 'POST',
 | |
|       };
 | |
|       // FIXME http open groups v2 are not working
 | |
|       // tslint:disable-next-line: no-http-string
 | |
|       if (finalRelayOptions?.protocol === 'http') {
 | |
|         dest.protocol = finalRelayOptions.protocol;
 | |
|         dest.port = finalRelayOptions.port || 80;
 | |
|       }
 | |
|     } else {
 | |
|       // set x25519 if destination snode
 | |
|       let pubkeyHex = targetED25519Hex; // relayingToFinalDestination
 | |
|       // or ed25519 snode destination
 | |
|       if (!relayingToFinalDestination) {
 | |
|         pubkeyHex = nodePath[i + 1].pubkey_ed25519;
 | |
|         if (!pubkeyHex) {
 | |
|           log.error(
 | |
|             `loki_rpc:::buildOnionGuardNodePayload ${id} - no ed25519 for`,
 | |
|             nodePath[i + 1],
 | |
|             'path node',
 | |
|             i + 1
 | |
|           );
 | |
|         }
 | |
|       }
 | |
|       // destination takes a hex key
 | |
|       dest = {
 | |
|         destination: pubkeyHex,
 | |
|       };
 | |
|     }
 | |
|     try {
 | |
|       // eslint-disable-next-line no-await-in-loop
 | |
|       const ctx = await encryptForRelayV2(nodePath[i].pubkey_x25519, dest, ctxes[ctxes.length - 1]);
 | |
|       ctxes.push(ctx);
 | |
|     } catch (e) {
 | |
|       log.error(
 | |
|         `loki_rpc:::buildOnionGuardNodePayload ${id} - encryptForRelayV2 failure`,
 | |
|         e.code,
 | |
|         e.message
 | |
|       );
 | |
|       throw e;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   return ctxes;
 | |
| }
 | |
| 
 | |
| // we just need the targetNode.pubkey_ed25519 for the encryption
 | |
| // targetPubKey is ed25519 if snode is the target
 | |
| async function buildOnionGuardNodePayload(
 | |
|   nodePath: Array<Snode>,
 | |
|   destCtx: DestinationContext,
 | |
|   targetED25519Hex?: string,
 | |
|   finalRelayOptions?: FinalRelayOptions,
 | |
|   id = ''
 | |
| ) {
 | |
|   const ctxes = await buildOnionCtxs(nodePath, destCtx, targetED25519Hex, finalRelayOptions, id);
 | |
| 
 | |
|   // this is the OUTER side of the onion, the one encoded with multiple layer
 | |
|   // So the one we will send to the first guard node.
 | |
|   const guardCtx = ctxes[ctxes.length - 1]; // last ctx
 | |
| 
 | |
|   // New "semi-binary" encoding
 | |
| 
 | |
|   const guardPayloadObj = {
 | |
|     ephemeral_key: toHex(guardCtx.ephemeralKey),
 | |
|   };
 | |
| 
 | |
|   return encodeCiphertextPlusJson(guardCtx.ciphertext, guardPayloadObj);
 | |
| }
 | |
| 
 | |
| // Process a response as it arrives from `fetch`, handling
 | |
| // http errors and attempting to decrypt the body with `sharedKey`
 | |
| // May return false BAD_PATH, indicating that we should try a new path.
 | |
| const processOnionResponse = async (
 | |
|   reqIdx: number,
 | |
|   response: Response,
 | |
|   symmetricKey: ArrayBuffer,
 | |
|   debug: boolean,
 | |
|   abortSignal?: AbortSignal
 | |
| ): Promise<SnodeResponse | RequestError> => {
 | |
|   const { log, libloki } = window;
 | |
| 
 | |
|   if (abortSignal?.aborted) {
 | |
|     log.warn(`(${reqIdx}) [path] Call aborted`);
 | |
|     return RequestError.ABORTED;
 | |
|   }
 | |
| 
 | |
|   // FIXME: 401/500 handling?
 | |
| 
 | |
|   // detect SNode is deregisted?
 | |
|   if (response.status === 502) {
 | |
|     log.warn(`(${reqIdx}) [path] Got 502: snode not found`);
 | |
| 
 | |
|     return RequestError.BAD_PATH;
 | |
|   }
 | |
| 
 | |
|   // detect SNode is not ready (not in swarm; not done syncing)
 | |
|   if (response.status === 503) {
 | |
|     log.warn(`(${reqIdx}) [path] Got 503: snode not ready`);
 | |
| 
 | |
|     return RequestError.BAD_PATH;
 | |
|   }
 | |
| 
 | |
|   if (response.status === 504) {
 | |
|     log.warn(`(${reqIdx}) [path] Got 504: Gateway timeout`);
 | |
|     return RequestError.BAD_PATH;
 | |
|   }
 | |
| 
 | |
|   if (response.status === 404) {
 | |
|     // Why would we get this error on testnet?
 | |
|     log.warn(`(${reqIdx}) [path] Got 404: Gateway timeout`);
 | |
|     return RequestError.BAD_PATH;
 | |
|   }
 | |
| 
 | |
|   if (response.status !== 200) {
 | |
|     const rsp = await response.text();
 | |
| 
 | |
|     log.warn(
 | |
|       `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}: ${rsp}`
 | |
|     );
 | |
|     // FIXME audric
 | |
|     // this is pretty strong but on the current setup.
 | |
|     // we have to increase a snode invididually and only mark later the path as bad
 | |
|     // the way it works on mobile is that we treat a node as bad in that case, and if it then reaches a failure count of 3 or so we kick it out and rebuild the path
 | |
|     return RequestError.BAD_PATH;
 | |
|   }
 | |
| 
 | |
|   let ciphertext = await response.text();
 | |
|   if (!ciphertext) {
 | |
|     log.warn(
 | |
|       `(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext`
 | |
|     );
 | |
|     return RequestError.OTHER;
 | |
|   }
 | |
|   if (debug) {
 | |
|     log.debug(`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertext`, ciphertext);
 | |
|   }
 | |
| 
 | |
|   let plaintext;
 | |
|   let ciphertextBuffer;
 | |
| 
 | |
|   try {
 | |
|     const jsonRes = JSON.parse(ciphertext);
 | |
|     ciphertext = jsonRes.result;
 | |
|   } catch (e) {
 | |
|     // just try to get a json object from what is inside (for PN requests), if it fails, continue ()
 | |
|   }
 | |
|   try {
 | |
|     ciphertextBuffer = fromBase64ToArrayBuffer(ciphertext);
 | |
| 
 | |
|     if (debug) {
 | |
|       log.debug(
 | |
|         `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
 | |
|         toHex(ciphertextBuffer)
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     const plaintextBuffer = await libloki.crypto.DecryptAESGCM(symmetricKey, ciphertextBuffer);
 | |
|     if (debug) {
 | |
|       log.debug('lokiRpc::processOnionResponse - plaintextBuffer', plaintextBuffer.toString());
 | |
|     }
 | |
| 
 | |
|     plaintext = new TextDecoder().decode(plaintextBuffer);
 | |
|   } catch (e) {
 | |
|     log.error(`(${reqIdx}) [path] lokiRpc::processOnionResponse - decode error`, e);
 | |
|     log.error(
 | |
|       `(${reqIdx}) [path] lokiRpc::processOnionResponse - symmetricKey`,
 | |
|       toHex(symmetricKey)
 | |
|     );
 | |
|     if (ciphertextBuffer) {
 | |
|       log.error(
 | |
|         `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
 | |
|         toHex(ciphertextBuffer)
 | |
|       );
 | |
|     }
 | |
|     return RequestError.OTHER;
 | |
|   }
 | |
| 
 | |
|   if (debug) {
 | |
|     log.debug('lokiRpc::processOnionResponse - plaintext', plaintext);
 | |
|   }
 | |
| 
 | |
|   try {
 | |
|     const jsonRes: SnodeResponse = JSON.parse(plaintext, (key, value) => {
 | |
|       if (typeof value === 'number' && value > Number.MAX_SAFE_INTEGER) {
 | |
|         window.log.warn('Received an out of bounds js number');
 | |
|       }
 | |
|       return value;
 | |
|     });
 | |
| 
 | |
|     return jsonRes;
 | |
|   } catch (e) {
 | |
|     log.error(
 | |
|       `(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error outer json ${e.code} ${e.message} json: '${plaintext}'`
 | |
|     );
 | |
|     return RequestError.OTHER;
 | |
|   }
 | |
| };
 | |
| 
 | |
| export const snodeHttpsAgent = new https.Agent({
 | |
|   rejectUnauthorized: false,
 | |
| });
 | |
| 
 | |
| export type FinalRelayOptions = {
 | |
|   host: string;
 | |
|   protocol?: 'http' | 'https'; // default to https
 | |
|   port?: number; // default to 443
 | |
| };
 | |
| 
 | |
| export type DestinationContext = {
 | |
|   ciphertext: Uint8Array;
 | |
|   symmetricKey: ArrayBuffer;
 | |
|   ephemeralKey: ArrayBuffer;
 | |
| };
 | |
| 
 | |
| export type FinalDestinationOptions = {
 | |
|   destination_ed25519_hex?: string;
 | |
|   headers?: Record<string, string>;
 | |
|   body?: string;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  *
 | |
|  * Onion request looks like this
 | |
|  * Sender -> 1 -> 2 -> 3 -> Receiver
 | |
|  * 1, 2, 3 = onion Snodes
 | |
|  *
 | |
|  *
 | |
|  * @param nodePath the onion path to use to send the request
 | |
|  * @param finalDestOptions those are the options for the request from 3 to R. It contains for instance the payload and headers.
 | |
|  * @param finalRelayOptions  those are the options 3 will use to make a request to R. It contains for instance the host to make the request to
 | |
|  */
 | |
| const sendOnionRequest = async (
 | |
|   reqIdx: number,
 | |
|   nodePath: Array<Snode>,
 | |
|   destX25519Any: string,
 | |
|   finalDestOptions: {
 | |
|     destination_ed25519_hex?: string;
 | |
|     headers?: Record<string, string>;
 | |
|     body?: string;
 | |
|   },
 | |
|   finalRelayOptions?: FinalRelayOptions,
 | |
|   lsrpcIdx?: number,
 | |
|   abortSignal?: AbortSignal
 | |
| ): Promise<SnodeResponse | RequestError> => {
 | |
|   const { log } = window;
 | |
| 
 | |
|   let id = '';
 | |
|   if (lsrpcIdx !== undefined) {
 | |
|     id += `${lsrpcIdx}=>`;
 | |
|   }
 | |
|   if (reqIdx !== undefined) {
 | |
|     id += `${reqIdx}`;
 | |
|   }
 | |
| 
 | |
|   // get destination pubkey in array buffer format
 | |
|   let destX25519hex = destX25519Any;
 | |
|   if (typeof destX25519hex !== 'string') {
 | |
|     // convert AB to hex
 | |
|     window.log.warn('destX25519hex was not a string');
 | |
|     destX25519hex = toHex(destX25519Any as any);
 | |
|   }
 | |
| 
 | |
|   // safely build destination
 | |
|   let targetEd25519hex;
 | |
| 
 | |
|   if (finalDestOptions.destination_ed25519_hex) {
 | |
|     // snode destination
 | |
|     targetEd25519hex = finalDestOptions.destination_ed25519_hex;
 | |
|     // eslint-disable-next-line no-param-reassign
 | |
|     delete finalDestOptions.destination_ed25519_hex;
 | |
|   }
 | |
| 
 | |
|   const options = finalDestOptions; // lint
 | |
|   // do we need this?
 | |
|   options.headers = options.headers || {};
 | |
| 
 | |
|   const isLsrpc = !!finalRelayOptions;
 | |
| 
 | |
|   let destCtx: DestinationContext;
 | |
|   try {
 | |
|     if (!isLsrpc) {
 | |
|       const body = options.body || '';
 | |
|       delete options.body;
 | |
| 
 | |
|       const textEncoder = new TextEncoder();
 | |
|       const bodyEncoded = textEncoder.encode(body);
 | |
| 
 | |
|       const plaintext = encodeCiphertextPlusJson(bodyEncoded, options);
 | |
|       destCtx = await window.libloki.crypto.encryptForPubkey(destX25519hex, plaintext);
 | |
|     } else {
 | |
|       destCtx = await encryptForPubKey(destX25519hex, options);
 | |
|     }
 | |
|   } catch (e) {
 | |
|     log.error(
 | |
|       `loki_rpc::sendOnionRequest ${id} - encryptForPubKey failure [`,
 | |
|       e.code,
 | |
|       e.message,
 | |
|       '] destination X25519',
 | |
|       destX25519hex.substr(0, 32),
 | |
|       '...',
 | |
|       destX25519hex.substr(32),
 | |
|       'options',
 | |
|       options
 | |
|     );
 | |
|     throw e;
 | |
|   }
 | |
| 
 | |
|   const payload = await buildOnionGuardNodePayload(
 | |
|     nodePath,
 | |
|     destCtx,
 | |
|     targetEd25519hex,
 | |
|     finalRelayOptions,
 | |
|     id
 | |
|   );
 | |
| 
 | |
|   const guardFetchOptions = {
 | |
|     method: 'POST',
 | |
|     body: payload,
 | |
|     // we are talking to a snode...
 | |
|     agent: snodeHttpsAgent,
 | |
|     abortSignal,
 | |
|   };
 | |
| 
 | |
|   const target = '/onion_req/v2';
 | |
| 
 | |
|   const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}${target}`;
 | |
|   // no logs for that one as we do need to call insecureNodeFetch to our guardNode
 | |
|   // window.log.info('insecureNodeFetch => plaintext for sendOnionRequest');
 | |
| 
 | |
|   const response = await insecureNodeFetch(guardUrl, guardFetchOptions);
 | |
|   return processOnionResponse(reqIdx, response, destCtx.symmetricKey, false, abortSignal);
 | |
| };
 | |
| 
 | |
| async function sendOnionRequestSnodeDest(
 | |
|   reqIdx: any,
 | |
|   nodePath: Array<Snode>,
 | |
|   targetNode: Snode,
 | |
|   plaintext: any
 | |
| ) {
 | |
|   return sendOnionRequest(
 | |
|     reqIdx,
 | |
|     nodePath,
 | |
|     targetNode.pubkey_x25519,
 | |
|     {
 | |
|       destination_ed25519_hex: targetNode.pubkey_ed25519,
 | |
|       body: plaintext,
 | |
|     },
 | |
|     undefined,
 | |
|     undefined
 | |
|   );
 | |
| }
 | |
| 
 | |
| // need relay node's pubkey_x25519_hex
 | |
| export async function sendOnionRequestLsrpcDest(
 | |
|   reqIdx: number,
 | |
|   nodePath: Array<Snode>,
 | |
|   destX25519Any: string,
 | |
|   finalRelayOptions: FinalRelayOptions,
 | |
|   payloadObj: FinalDestinationOptions,
 | |
|   lsrpcIdx: number,
 | |
|   abortSignal?: AbortSignal
 | |
| ): Promise<SnodeResponse | RequestError> {
 | |
|   return sendOnionRequest(
 | |
|     reqIdx,
 | |
|     nodePath,
 | |
|     destX25519Any,
 | |
|     payloadObj,
 | |
|     finalRelayOptions,
 | |
|     lsrpcIdx,
 | |
|     abortSignal
 | |
|   );
 | |
| }
 | |
| 
 | |
| function getPathString(pathObjArr: Array<any>): string {
 | |
|   return pathObjArr.map(node => `${node.ip}:${node.port}`).join(', ');
 | |
| }
 | |
| 
 | |
| export async function lokiOnionFetch(body: any, targetNode: Snode): Promise<SnodeResponse | false> {
 | |
|   const { log } = window;
 | |
| 
 | |
|   // Loop until the result is not BAD_PATH
 | |
|   // tslint:disable-next-line no-constant-condition
 | |
|   while (true) {
 | |
|     // Get a path excluding `targetNode`:
 | |
|     // eslint-disable-next-line no-await-in-loop
 | |
|     const path = await OnionPaths.getInstance().getOnionPath(targetNode);
 | |
|     const thisIdx = OnionPaths.getInstance().assignOnionRequestNumber();
 | |
| 
 | |
|     // At this point I only care about BAD_PATH
 | |
| 
 | |
|     // eslint-disable-next-line no-await-in-loop
 | |
|     const result = await sendOnionRequestSnodeDest(thisIdx, path, targetNode, body);
 | |
| 
 | |
|     if (result === RequestError.BAD_PATH) {
 | |
|       log.error(
 | |
|         `[path] Error on the path: ${getPathString(path)} to ${targetNode.ip}:${targetNode.port}`
 | |
|       );
 | |
|       OnionPaths.getInstance().markPathAsBad(path);
 | |
|       return false;
 | |
|     } else if (result === RequestError.OTHER) {
 | |
|       // could mean, fail to parse results
 | |
|       // or status code wasn't 200
 | |
|       // or can't decrypt
 | |
|       // it's not a bad_path, so we don't need to mark the path as bad
 | |
|       log.error(
 | |
|         `[path] sendOnionRequest gave false for path: ${getPathString(path)} to ${targetNode.ip}:${
 | |
|           targetNode.port
 | |
|         }`
 | |
|       );
 | |
|       return false;
 | |
|     } else if (result === RequestError.ABORTED) {
 | |
|       // could mean, fail to parse results
 | |
|       // or status code wasn't 200
 | |
|       // or can't decrypt
 | |
|       // it's not a bad_path, so we don't need to mark the path as bad
 | |
|       log.error(
 | |
|         `[path] sendOnionRequest gave aborted for path: ${getPathString(path)} to ${
 | |
|           targetNode.ip
 | |
|         }:${targetNode.port}`
 | |
|       );
 | |
|       return false;
 | |
|     } else {
 | |
|       return result;
 | |
|     }
 | |
|   }
 | |
| }
 |