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