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.
session-desktop/ts/session/snode_api/onions.ts

454 lines
11 KiB
TypeScript

import fetch from 'node-fetch';
import https from 'https';
import { Snode } from './snodePool';
import ByteBuffer from 'bytebuffer';
import { StringUtils } from '../utils';
import { OnionAPI } from '../onions';
enum RequestError {
BAD_PATH,
OTHER,
}
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) {
const reqStr = JSON.stringify(reqObj);
const textEncoder = new TextEncoder();
const plaintext = textEncoder.encode(reqStr);
return window.libloki.crypto.encryptForPubkey(pubKeyX25519hex, plaintext);
}
// `ctx` holds info used by `node` to relay further
async function encryptForRelay(
relayX25519hex: string,
destination: any,
ctx: any
) {
const { log, dcodeIO, StringView } = window;
// ctx contains: ciphertext, symmetricKey, ephemeralKey
const payload = ctx.ciphertext;
if (!destination.host && !destination.destination) {
log.warn('loki_rpc::encryptForRelay - no destination', destination);
}
const reqObj = {
...destination,
ciphertext: ByteBuffer.wrap(payload).toString('base64'),
ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeralKey),
};
return encryptForPubKey(relayX25519hex, reqObj);
}
async function makeGuardPayload(guardCtx: any) {
const ciphertextBase64 = StringUtils.decode(guardCtx.ciphertext, 'base64');
const guardPayloadObj = {
ciphertext: ciphertextBase64,
ephemeral_key: StringUtils.decode(guardCtx.ephemeralKey, 'hex'),
};
return guardPayloadObj;
}
// we just need the targetNode.pubkey_ed25519 for the encryption
// targetPubKey is ed25519 if snode is the target
async function makeOnionRequest(
nodePath: Array<Snode>,
destCtx: any,
targetED25519Hex: string,
finalRelayOptions?: any,
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;
const relayingToFinalDestination = i === firstPos; // if last position
if (relayingToFinalDestination && finalRelayOptions) {
dest = {
host: finalRelayOptions.host,
target: '/loki/v1/lsrpc',
method: 'POST',
};
} 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:::makeOnionRequest ${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 encryptForRelay(
nodePath[i].pubkey_x25519,
dest,
ctxes[ctxes.length - 1]
);
ctxes.push(ctx);
} catch (e) {
log.error(
`loki_rpc:::makeOnionRequest ${id} - encryptForRelay failure`,
e.code,
e.message
);
throw e;
}
}
const guardCtx = ctxes[ctxes.length - 1]; // last ctx
const payloadObj = makeGuardPayload(guardCtx);
// all these requests should use AesGcm
return payloadObj;
}
// 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: any,
response: any,
sharedKey: any,
useAesGcm: boolean,
debug: boolean
): Promise<SnodeResponse | RequestError> => {
const { log, libloki, StringView, dcodeIO } = window;
// FIXME: 401/500 handling?
// 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) {
log.warn(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}`
);
return RequestError.OTHER;
}
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 = dcodeIO.ByteBuffer.wrap(
ciphertext,
'base64'
).toArrayBuffer();
if (debug) {
log.debug(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
StringView.arrayBufferToHex(ciphertextBuffer),
'useAesGcm',
useAesGcm
);
}
const decryptFn = useAesGcm
? libloki.crypto.DecryptGCM
: libloki.crypto.DHDecrypt;
const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer, debug);
if (debug) {
log.debug(
'lokiRpc::processOnionResponse - plaintextBuffer',
plaintextBuffer.toString()
);
}
const textDecoder = new TextDecoder();
plaintext = textDecoder.decode(plaintextBuffer);
} catch (e) {
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - decode error`,
e.code,
e.message
);
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - symKey`,
StringView.arrayBufferToHex(sharedKey)
);
if (ciphertextBuffer) {
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`,
StringView.arrayBufferToHex(ciphertextBuffer)
);
}
return RequestError.OTHER;
}
if (debug) {
log.debug('lokiRpc::processOnionResponse - plaintext', plaintext);
}
try {
const jsonRes = JSON.parse(plaintext);
return jsonRes;
} catch (e) {
log.error(
`(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error outer json`,
e.code,
e.message,
'json:',
plaintext
);
return RequestError.OTHER;
}
};
const snodeHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
// finalDestOptions is an object
// FIXME: internally track reqIdx, not externally
const sendOnionRequest = async (
reqIdx: any,
nodePath: Array<Snode>,
destX25519Any: string,
finalDestOptions: any,
finalRelayOptions?: any,
lsrpcIdx?: any
) => {
const { log, StringView } = window;
// loki-storage may need this to function correctly
// but ADN calls will not always have a body
/*
if (!finalDestOptions.body) {
finalDestOptions.body = '';
}
*/
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
destX25519hex = StringView.arrayBufferToHex(destX25519Any);
}
// 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?
if (options.headers === undefined) {
options.headers = '';
}
let destCtx;
try {
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 payloadObj = await makeOnionRequest(
nodePath,
destCtx,
targetEd25519hex,
finalRelayOptions,
id
);
const guardFetchOptions = {
method: 'POST',
body: JSON.stringify(payloadObj),
// we are talking to a snode...
agent: snodeHttpsAgent,
};
const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`;
const response = await fetch(guardUrl, guardFetchOptions);
return processOnionResponse(
reqIdx,
response,
destCtx.symmetricKey,
true,
false
);
};
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
// always the same target: /loki/v1/lsrpc
export async function sendOnionRequestLsrpcDest(
reqIdx: any,
nodePath: Array<Snode>,
destX25519Any: any,
host: any,
payloadObj: any,
lsrpcIdx: number
) {
return sendOnionRequest(
reqIdx,
nodePath,
destX25519Any,
payloadObj,
{ host },
lsrpcIdx
);
}
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 | boolean> {
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 OnionAPI.getOnionPath(targetNode);
const thisIdx = OnionAPI.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
}`
);
OnionAPI.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 {
return result;
}
}
}