All files remote-calls.ts

14.91% Statements 17/114
5.13% Branches 2/39
5.88% Functions 1/17
14.55% Lines 16/110

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 2615x 5x 5x 5x                                                                                     5x       5x                     5x                                                                             5x           5x 72x     5x                             5x 5x                                                                                 5x                                                                               5x 5x                                                                                                        
import * as _ from "lodash";
import { fromJSON, isid, newid,  } from "./common";
import { getDB } from "./db";
import { keysEqual, IUser, openMessage, signMessage, verifySignedObject } from "./user";
 
export type txfn = <T>(data: (string | IRemoteData)) => Promise<T | void> | void
 
export interface IConnection {
  id: string
  remoteDeviceId: string
  lastAck: number //time
  handlers: { [key: string]: ((err: any, result: any) => void) }
  send: txfn
  close: () => void
  closed?: true
  me?: IUser
  remoteUser?: IUser
  remoteUserVerified?: boolean
  groups?: string[]
  pingMS?: number
}
 
export interface IRemoteData {
  type: 'call' | 'response' | 'chunk'
  id: string
}
 
export interface IRemoteCall extends IRemoteData {
  type: 'call'
  fnName: string
  args: any[]
}
 
export interface IRemoteResponse extends IRemoteData {
  type: 'response'
  result?: any
  error?: any
}
 
export interface IRemoteChunk extends IRemoteData {
  type: 'chunk',
  iChunk: number,
  totalChunks: number
  chunk: string,
}
 
export async function ping(...args) {
  return ['pong', ...args];
}
 
export async function testError(msg: string) {
  throw new Error(msg);
}
 
async function signId(id: string) {
  Iif (!isid(id)) {
    throw new Error('Only single ids are signed to prevent abuse');
  }
  return signMessage(id);
}
 
export async function verifyRemoteUser(connection: IConnection) {
  try {
    Iif (connection.remoteUserVerified) {
      return;
    }
    const id = newid();
    const signedId = await RPC(connection, signId)(id);
    const openedId = openMessage(signedId, connection.remoteUser.publicKey);
    Iif (openedId != id) {
      throw new Error('Failed to verify possession of correct secretKey')
    }
    verifySignedObject(connection.remoteUser, connection.remoteUser.publicKey);
    const db = await getDB();
    const dbUser = await db.get(connection.remoteUser.id) as IUser;
    // TODO for all of the below issues see: host.ts and auth.ts, both are WIPs
    Iif (dbUser && !keysEqual(dbUser.publicKey, connection.remoteUser.publicKey)) {
      // TODO allow public keys to change
      //    this will have to happen if a user's private key is compromised so we need to plan for it
      //    The obvious solution is to use some server as a source of truth but that kind of violates the p2p model
      throw new Error("Remote user's pubic key does not match what we have in db");
      // IDEA use previously known devices to try to do multi-factor authentication
      //    If the user has two or more devices they regularly use, we can ask as many of those devices
      //    as we can connect with, which is the correct public key for their user.
      //    we can reject the new public key until all available devices belonging to the user are in consensus.
    }
    Iif (!dbUser || dbUser.modified < connection.remoteUser.modified) {
      // TODO protect from users stealing other users' ids
      //    this can happen if user1 has never seen user2 before, and user3 creates a user object
      //    with user2's id but a new public/private key, then gives that to user1
      //    MAYBE ask any other peers if they have this user and if so check that public keys match
      await db.save(connection.remoteUser);
    }
  } catch (err) {
    throw new Error('remote user failed verification: ' + String(err));
  }
  connection.remoteUserVerified = true;
}
 
 
const remotelyCallableFunctions: { [key: string]: Function } = {
  ping,
  testError,
  signId,
}
 
export function setRemotelyCallableFunction(fn: Function, name?: string) {
  remotelyCallableFunctions[name || fn.name] = fn;
}
 
export function RPC<T extends Function>(connection: IConnection, fn: T): T {
  return <any>function (...args) {
    const fnName = Object.keys(remotelyCallableFunctions).find(fnName => remotelyCallableFunctions[fnName] == fn);
    Iif (fnName === "ping") {
      const sTime = Date.now();
      return makeRemoteCall(connection, fnName as any, args).then(result => {
        const eTime = Date.now();
        connection.pingMS = eTime - sTime;
        return result;
      })
    }
    return makeRemoteCall(connection, fnName as any, args);
  };
}
 
export const RPC_TIMEOUT_MS = 15_000;
export async function makeRemoteCall(connection: IConnection, fnName: string, args: any[]) {
  const id = newid();
  let rejectRemoteCall;
 
  const remoteCallPromise = new Promise((resolve, reject) => {
    rejectRemoteCall = reject;
    const pid = setTimeout(() => {
      delete connection.handlers[id]  
      reject(`RPC timeout: ${fnName}(${args.join(',')})`);
    }, 10_000);
    connection.handlers[id] = (err, result) => {
      clearTimeout(pid);
      err ? reject(err) : resolve(result);
    }
  });
  try {
    let remoteCall: IRemoteCall = {
      type: 'call',
      id,
      fnName,
      args
    }
    // WebRTC is already encrypted so signing the call object seems wasteful
    // remoteCall = signObject(remoteCall);
    connection.send(remoteCall);
  } catch (err) {
    rejectRemoteCall(err);
  }
  return remoteCallPromise;
}
 
async function sendRemoteError(connection: IConnection, callId: string, error: string) {
  let response: IRemoteResponse = {
    type: 'response',
    id: callId,
    error
  }
  connection.send(response);
}
 
let currentConnection: IConnection;
export const getCurrentConnection = () => currentConnection;
 
async function handelRemoteCall(connection: IConnection, remoteCall: IRemoteCall) {
  const { id, fnName, args } = remoteCall;
  try {
    // WebRTC is already encrypted so verifying at this level seems wasteful (see `verifyRemoteUser` below)
    // verifySignedObject(remoteCall as any, connection.remoteUser.publicKey);
    const fn = remotelyCallableFunctions[fnName];
    let result;
    let error;
    if (typeof fn !== 'function') {
      error = `${fnName} is not a remotely callable function`;
    } else {
      try {
        Iif (!connection.remoteUserVerified && fn != signId) {
          await verifyRemoteUser(connection);
          // console.log('remote user verified', { deviceId: connection.remoteDeviceId, userId: connection.remoteUser?.id })
        }
        // make the current connection available to the fn when it is called
        currentConnection = connection;
        const resultPromise = fn(...args);
        // unset current connection as soon as possible to prevent weird usage
        currentConnection = null;
        result = await resultPromise;
      } catch (err) {
        error = String(err);
      }
    }
    let response: IRemoteResponse = {
      type: 'response',
      id,
      result,
      error
    }
    connection.send(response);
  } catch (err) {
    sendRemoteError(connection, id, 'unhandled error in handelRemoteCall: ' + err);
  }
}
 
const messageChunks = {};
export function onRemoteMessage(connection: IConnection, message: string | IRemoteData): void {
  // console.log({ connection, message })
  // TODO check if fromJSON calls eval, if so this is a security hole
  message = fromJSON(JSON.parse(message as any));
  connection.lastAck = Date.now();
  Iif (message === 'ack') return;
  Iif (message == 'ping') {
    console.log('ping!', { deviceId: connection.remoteDeviceId, userId: connection.remoteUser?.id })
    connection.send('pong');
    return;
  }
  Iif (message == 'pong') {
    console.log('pong!', { deviceId: connection.remoteDeviceId, userId: connection.remoteUser?.id })
    return;
  }
  const msgObj = message as IRemoteCall | IRemoteResponse | IRemoteChunk;
 
  Iif (msgObj.type === 'chunk') {
    // validate size to prevent remote attacker filling up memory
    Iif (msgObj.totalChunks * msgObj.chunk.length > 1e9) {
      throw new Error(`Message larger than maximum allowed size of ${1e8} (~100Mb), use files for very large objects or write a custom function to stream the data`)
    }
    Iif (!messageChunks[msgObj.id]) {
      messageChunks[msgObj.id] = [];
    }
    const chunks = messageChunks[msgObj.id];
    chunks[msgObj.iChunk] = msgObj.chunk;
    Iif (_.compact(chunks).length === msgObj.totalChunks) {
      delete messageChunks[msgObj.id];
      onRemoteMessage(connection, chunks.join(''));
    }
    return;
  }
 
  switch (msgObj.type) {
    case 'call':
      handelRemoteCall(connection, msgObj);
      break;
    case 'response':
      const handler = connection.handlers[msgObj.id]
      if (handler) {
        handler(msgObj.error, msgObj.result);
        delete connection.handlers[msgObj.id];
      } else {
        /* istanbul ignore next */
        console.error('no handler for remote response', connection, msgObj)
      }
      break;
    default:
      // @ts-ignore
      sendRemoteError(connection, msgObj.id, 'unknown remote call: ' + msgObj.type)
  }
}