diff --git a/js/modules/loki_rpc.js b/js/modules/loki_rpc.js index d2b8f69fd..ab84323f4 100644 --- a/js/modules/loki_rpc.js +++ b/js/modules/loki_rpc.js @@ -1,5 +1,5 @@ /* global log, libloki, textsecure, getStoragePubKey, lokiSnodeAPI, StringView, - libsignal, window, TextDecoder, TextEncoder, dcodeIO, process */ + libsignal, window, TextDecoder, TextEncoder, dcodeIO, process, crypto */ const nodeFetch = require('node-fetch'); const https = require('https'); @@ -16,138 +16,364 @@ let onionReqIdx = 0; // Returns the actual ciphertext, symmetric key that will be used // for decryption, and an ephemeral_key to send to the next hop -const encryptForPubKey = async (pubKeyAB, reqJson) => { +const encryptForPubKey = async (pubKeyX25519AB, reqObj, debug = false) => { // Do we still need "headers"? - - const reqStr = JSON.stringify(reqJson); + const reqStr = JSON.stringify(reqObj); const textEncoder = new TextEncoder(); const plaintext = textEncoder.encode(reqStr); - const ephemeral = libloki.crypto.generateEphemeralKeyPair(); + const ephemeral = await libloki.crypto.generateEphemeralKeyPair(); + if (debug) { + log.debug( + 'encryptForPubKey', + debug, + '- pubKeyX25519AB', + StringView.arrayBufferToHex(pubKeyX25519AB) + ); + log.debug( + 'encryptForPubKey', + debug, + '- ephermalPriv', + StringView.arrayBufferToHex(ephemeral.privKey) + ); + log.debug( + 'encryptForPubKey', + debug, + '- ephermalPub', + StringView.arrayBufferToHex(ephemeral.pubKey) + ); + } const ephemeralSecret = libsignal.Curve.calculateAgreement( - pubKeyAB, + pubKeyX25519AB, ephemeral.privKey ); + if (debug) { + log.debug( + 'encryptForPubKey', + debug, + '- ephemeralSecret', + StringView.arrayBufferToHex(ephemeralSecret) + ); + } - const salt = window.Signal.Crypto.bytesFromString('LOKI'); + // FIXME: window.libloki.crypto.deriveSymmetricKey refactor + const salt = window.Signal.Crypto.bytesFromString('LOKI'); // ArrayBuffer (object) + if (debug) { + log.debug( + 'encryptForPubKey', + debug, + '- salt', + StringView.arrayBufferToHex(salt) + ); + } const key = await crypto.subtle.importKey( 'raw', salt, { name: 'HMAC', hash: { name: 'SHA-256' } }, - false, + true, ['sign'] ); + + // CrsptoKey (object) + const exportKey = await crypto.subtle.exportKey('raw', key); + // ArrayBuffer (object) + if (debug) { + log.error( + 'encryptForPubKey', + debug, + '- key', + StringView.arrayBufferToHex(exportKey) + ); + } + const symmetricKey = await crypto.subtle.sign( { name: 'HMAC', hash: 'SHA-256' }, key, ephemeralSecret ); + // ArrayBuffer (object) + if (debug) { + log.debug( + 'encryptForPubKey', + debug, + '- symmetricKey', + StringView.arrayBufferToHex(symmetricKey) + ); + } const ciphertext = await window.libloki.crypto.EncryptGCM( symmetricKey, - plaintext + plaintext, + debug, + ephemeral.pubKey ); + // looks textEncoder'd... Uint8Array + if (debug) { + log.debug( + 'encryptForPubKey', + debug, + '- ciphertext', + StringView.arrayBufferToHex(ciphertext), + ciphertext + ); + } + // ephemeral_key => ephemeralKey? return { ciphertext, symmetricKey, ephemeral_key: ephemeral.pubKey }; }; // `ctx` holds info used by `node` to relay further -const encryptForRelay = async (node, nextNodePubKey_ed25519_hex, ctx) => { +// destination needs ed25519_hex +const encryptForRelay = async (relayX25519AB, destination, ctx) => { + // cyx contains: ciphertext, symmetricKey, ephemeral_key const payload = ctx.ciphertext; - // ciphertext, symmetricKey, ephemeral_key - //console.log('encryptForRelay ctx', ctx) - const reqJson = { + if (!destination.host && !destination.destination) { + log.warn(`loki_rpc::encryptForRelay - no destination`, destination); + } + + const reqObj = { + ...destination, ciphertext: dcodeIO.ByteBuffer.wrap(payload).toString('base64'), ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeral_key), - destination: nextNodePubKey_ed25519_hex, - //ephemeral_key: StringView.arrayBufferToHex(ctx.ephemeralKey), - //destination: nextNode.pubkey_ed25519, }; - const snPubkey = StringView.hexToArrayBuffer(node.pubkey_x25519); - return encryptForPubKey(snPubkey, reqJson); + return encryptForPubKey(relayX25519AB, reqObj); }; -const BAD_PATH = 'bad_path'; +const makeGuardPayload = guardCtx => { + const ciphertextBase64 = dcodeIO.ByteBuffer.wrap( + guardCtx.ciphertext + ).toString('base64'); + + const guardPayloadObj = { + ciphertext: ciphertextBase64, + ephemeral_key: StringView.arrayBufferToHex(guardCtx.ephemeral_key), + }; + return guardPayloadObj; +}; -// May return false BAD_PATH, indicating that we should try a new // we just need the targetNode.pubkey_ed25519 for the encryption // targetPubKey is ed25519 if snode is the target -const makeOnionRequest = async (nodePath, destCtx, targetPubKey) => { +const makeOnionRequest = async ( + nodePath, + destCtx, + targetED25519Hex, + finalRelayOptions = false, + id = '' +) => { const ctxes = [destCtx]; // from (3) 2 to 0 const firstPos = nodePath.length - 1; - // console.log('targetPubKey', targetPubKey) + // console.log('targetED25519Hex', targetED25519Hex) // console.log('nodePath', nodePath.length, 'first', firstPos) for (let i = firstPos; i > -1; i -= 1) { - // console.log('makeOnionRequest - encryptForRelay', i) - // this nodePath points to the previous (i + 1) context - // console.log(i + 1, 'pubkey_ed25519', nodePath[i + 1] ? nodePath[i + 1].pubkey_ed25519 : null) - // console.log('node', i, 'to', i === firstPos ? targetPubKey : nodePath[i + 1].pubkey_ed25519) - ctxes.push( - // eslint-disable-next-line no-await-in-loop - await encryptForRelay( - nodePath[i], - i === firstPos ? targetPubKey : nodePath[i + 1].pubkey_ed25519, - ctxes[ctxes.length - 1] - ) + let dest; + const relayingToFinalDestination = i === 0; // if last position + + if (relayingToFinalDestination && finalRelayOptions) { + dest = { + host: finalRelayOptions.host, + target: '/loki/v1/lsrpc', + method: 'POST', + }; + log.info( + `loki_rpc:::makeOnionRequest ${id} - lsrpc destination set`, + dest + ); + } 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, + }; + } + // FIXME: we should store this inside snode pool + const relayX25519AB = StringView.hexToArrayBuffer( + nodePath[i].pubkey_x25519 ); + try { + ctxes.push( + // eslint-disable-next-line no-await-in-loop + await encryptForRelay(relayX25519AB, dest, ctxes[ctxes.length - 1]) + ); + } 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 ciphertextBase64 = dcodeIO.ByteBuffer.wrap( - guardCtx.ciphertext - ).toString('base64'); - - const payloadObj = { - ciphertext: ciphertextBase64, - ephemeral_key: StringView.arrayBufferToHex(guardCtx.ephemeralKey), - }; + const payloadObj = makeGuardPayload(guardCtx); // all these requests should use AesGcm return payloadObj; }; -// May return false BAD_PATH, indicating that we should try a new -const sendOnionRequest = async (reqIdx, nodePath, targetNode, plaintext, options = {}) => { - if (!targetNode) { - console.trace('loki_rpc::sendOnionRequest - no targetNode given') - return {} +// finalDestOptions is an object +// FIXME: internally track reqIdx, not externally +const sendOnionRequest = async ( + reqIdx, + nodePath, + destX25519Any, + finalDestOptions, + finalRelayOptions = false, + lsrpcIdx +) => { + if (!destX25519Any) { + log.error('loki_rpc::sendOnionRequest - no destX25519Any given'); + return {}; } - if (!targetNode.pubkey_x25519) { - console.trace('loki_rpc::sendOnionRequest - pubkey_x25519 in targetNode', targetNode) - return {} + + // loki-storage may need this to function correctly + // but ADN calls will not always have a body + /* + if (!finalDestOptions.body) { + finalDestOptions.body = ''; } - const snPubkey = StringView.hexToArrayBuffer(targetNode.pubkey_x25519); + */ - const destCtx = await encryptForPubKey(snPubkey, { - ...options, body: plaintext, headers: '', - }); + let id = ''; + if (lsrpcIdx !== undefined) { + id += `${lsrpcIdx}=>`; + } + if (reqIdx !== undefined) { + id += `${reqIdx}`; + } - const payloadObj = await makeOnionRequest(nodePath, destCtx, targetNode.pubkey_ed25519); + // get destination pubkey in array buffer format + let destX25519AB = destX25519Any; + if (typeof destX25519AB === 'string') { + destX25519AB = StringView.hexToArrayBuffer(destX25519Any); + } - const fetchOptions = { + // safely build destination + let targetEd25519hex; + if (finalDestOptions) { + if (finalDestOptions.destination_ed25519_hex) { + // snode destination + targetEd25519hex = finalDestOptions.destination_ed25519_hex; + // eslint-disable-next-line no-param-reassign + delete finalDestOptions.destination_ed25519_hex; + } + // else it's lsrpc... + } else { + // eslint-disable-next-line no-param-reassign + finalDestOptions = {}; + log.warn(`loki_rpc::sendOnionRequest ${id} - no finalDestOptions`); + return {}; + } + + const options = finalDestOptions; // lint + // do we need this? + if (options.headers === undefined) { + options.headers = ''; + } + + let destCtx; + try { + destCtx = await encryptForPubKey(destX25519AB, options); + } catch (e) { + const hex = StringView.arrayBufferToHex(destX25519AB); + log.error( + `loki_rpc::sendOnionRequest ${id} - encryptForPubKey failure [`, + e.code, + e.message, + '] destination X25519', + hex.substr(0, 32), + '...', + hex.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 url = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`; - const response = await nodeFetch(url, fetchOptions); + const guardUrl = `https://${nodePath[0].ip}:${nodePath[0].port}/onion_req`; + const response = await nodeFetch(guardUrl, guardFetchOptions); return processOnionResponse(reqIdx, response, destCtx.symmetricKey, true); }; +const sendOnionRequestSnodeDest = async ( + reqIdx, + nodePath, + targetNode, + plaintext +) => + sendOnionRequest(reqIdx, nodePath, targetNode.pubkey_x25519, { + destination_ed25519_hex: targetNode.pubkey_ed25519, + body: plaintext, + }); + +// need relay node's pubkey_x25519_hex +// always the same target: /loki/v1/lsrpc +const sendOnionRequestLsrpcDest = async ( + reqIdx, + nodePath, + destX25519Any, + host, + payloadObj, + lsrpcIdx = 0 +) => + sendOnionRequest( + reqIdx, + nodePath, + destX25519Any, + payloadObj, + { host }, + lsrpcIdx + ); + +const BAD_PATH = 'bad_path'; + // Process a response as it arrives from `nodeFetch`, handling // http errors and attempting to decrypt the body with `sharedKey` -const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => { +// May return false BAD_PATH, indicating that we should try a new path. +const processOnionResponse = async ( + reqIdx, + response, + sharedKey, + useAesGcm, + debug +) => { // FIXME: 401/500 handling? // detect SNode is not ready (not in swarm; not done syncing) @@ -170,18 +396,22 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => { if (response.status !== 200) { log.warn( - `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${response.status}` + `(${reqIdx}) [path] lokiRpc::processOnionResponse - fetch unhandled error code: ${ + response.status + }` ); return false; } const ciphertext = await response.text(); if (!ciphertext) { - log.warn(`(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext`); + log.warn( + `(${reqIdx}) [path] lokiRpc::processOnionResponse - Target node return empty ciphertext` + ); return false; } - if (reqIdx === 0) { - //console.log(`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertext`, ciphertext) + if (debug) { + // log.debug(`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertext`, ciphertext) } let plaintext; @@ -192,25 +422,61 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => { 'base64' ).toArrayBuffer(); - if (reqIdx === 0) { - console.log(`(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`, StringView.arrayBufferToHex(ciphertextBuffer), 'useAesGcm', useAesGcm) + if (debug) { + log.debug( + `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`, + StringView.arrayBufferToHex(ciphertextBuffer), + 'useAesGcm', + useAesGcm + ); } const decryptFn = useAesGcm ? window.libloki.crypto.DecryptGCM : window.libloki.crypto.DHDecrypt; - const plaintextBuffer = await decryptFn(sharedKey, ciphertextBuffer); + 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 - 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`, ciphertextBuffer); + log.error( + `(${reqIdx}) [path] lokiRpc::processOnionResponse - ciphertextBuffer`, + StringView.arrayBufferToHex(ciphertextBuffer) + ); } return false; } + /* + if (!plaintext) { + log.debug('Trying again with', useAesGcm?'gcm':'dh') + try { + const plaintextBuffer2 = await decryptFn(sharedKey, ciphertextBuffer, true); + log.info(`(${reqIdx}) [path] lokiRpc::processOnionResponse - plaintextBufferHex`, StringView.arrayBufferToHex(plaintextBuffer2)); + } catch(e) { + } + } + */ + + if (debug) { + log.debug('lokiRpc::processOnionResponse - plaintext', plaintext); + } try { const jsonRes = JSON.parse(plaintext); @@ -220,13 +486,22 @@ const processOnionResponse = async (reqIdx, response, sharedKey, useAesGcm) => { const res = JSON.parse(jsonRes.body); return res; } catch (e) { - log.error(`(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error json: `, jsonRes.body); + log.error( + `(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error inner json: `, + jsonRes.body + ); } return false; }; return jsonRes; } catch (e) { - log.error(`(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error`, e.code, e.message, `json:`, plaintext); + log.error( + `(${reqIdx}) [path] lokiRpc::processOnionResponse - parse error outer json`, + e.code, + e.message, + `json:`, + plaintext + ); return false; } }; @@ -523,6 +798,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => { // Wrong PoW difficulty if (response.status === 432) { const result = await response.json(); + log.error('WRONG POW', result); throw new textsecure.WrongDifficultyError(result.difficulty); } @@ -547,7 +823,7 @@ const lokiFetch = async (url, options = {}, targetNode = null) => { onionReqIdx += 1; // eslint-disable-next-line no-await-in-loop - const result = await sendOnionRequest( + const result = await sendOnionRequestSnodeDest( thisIdx, path, targetNode, @@ -707,4 +983,5 @@ module.exports = { encryptForPubKey, encryptForRelay, processOnionResponse, + sendOnionRequestLsrpcDest, };