Skip to content

Instantly share code, notes, and snippets.

@wbobeirne
Last active February 1, 2022 20:32
Show Gist options
  • Save wbobeirne/ec3e52b3db1359278c19f29e1bbfd5f1 to your computer and use it in GitHub Desktop.
Save wbobeirne/ec3e52b3db1359278c19f29e1bbfd5f1 to your computer and use it in GitHub Desktop.

Hardware Wallets on Electron

Getting hardware wallets on Electron is a pretty easy task if approached naively. Simply include the necessary node libraries, import them into your app, and you're done! Where this gets tricky is trying to do this securely. We're going to walk through a few of the approaches, why they didn't work, and why we settled on the final solution.

Using The Node Libs

Electron by default exposes Node to the web context, so you could easily do something like the following:

import ledger from 'ledgerjs';

class LedgerUnlock extends React.Component {
  render() {
    return <button onClick={ledger.open()}>Unlock!</button>
  }
}

This isn't the actual ledger API, but you get the point

However, by exposing Node to the web view, we open ourselves up to all sorts of attacks, which if succesful, will compromise the entirety of the user's machine, rather than just the current webview like on a traditional page. Modules like fs and child_process are as available as anything else. So we set the nodeIntegration: false option and disable this, making using native libraries like ledger-transport-node-hid unavailable in the web context.

Using IPC Communication

The suggested alternative is to keep your node code in Electron land, and communicate between Electron and the web view via ipcmain and ipcrenderer, signaling messages and data back and forth. The IPC libraries are also node libraries though, so we'd have to expose them via Electron's preload script, doing something like this:

// preload.ts
import { ipcRenderer } from 'electron';
window.ipcRenderer = ipcRenderer;
window.ipcRenderer.on('get-address', /* ... */);

// app.ts
window.ipcRenderer.send('get-address', /* ... */);

Enabling this alteration of the web context via preload opens us up to another set of attacks. Here's Cure53's comment on the issue:

Without this option, RCE can be achieved by overwriting native functions since preloaded Electron functions will run in the website context by default.

So with enabling contextIsolation, we lose the communication over IPC.

Using postMessage

All is not lost for preload, because despite being in isolated contexts, it still acts as node-enabled webview of sorts. Using the postMessage function, we can send one-way messages to other windows. This allows us to fire off similar requests to the ipcRenderer without exposing any node functionality to the webview.

However, even though this method works, it comes with a lot of undesirable traits. Communication is unidirectional and event driven, which means firing off a request for some action to be taken could simply go to the void, and our application would have no way of knowing. This means we have to implement strict timeouts to ensure the user doesn't stare at endless spinners, but that also puts them in a rush to perform whatever meatspace actions they need to.

Settling On Electron Protocols

When it comes down to it, HTTP is not bad for asynchronous cross-process communication. It's 2 way communication, baked standards for errors and success data, Electrons devtools make it easy to inspect requests without needing to setup logging, and it uses the familiar fetch API.

Electron allows us to create our own protocol via their protocol api. We can make a string-based protocol to send JSON back and forth, which after whitelisting our custom protocol, behaves almost entirely like a traditional HTTP request.

It bares more research, but I think the attack surface is quite low here, and the openness of the protocol better enforces us to be good custodians for the user. Websites loaded through the browser can't fire off requests to custom protocols, and while the documentation is sparse, I'm pretty sure we can lock this down to only be requestable within the app using the referrer property on the request object.

The End Result

A fully self contained module that provides the following API:

interface EnclaveAPI {
  getChainCode(params: {
    walletType: string;
    dpath: string;
  }): {
    publicKey: string;
    chainCode: string;
  };
  
  signTransaction(params: {
    walletType: string;
    path: string;
    transaction: {
      chainId: number;
      gasLimit: string;
      gasPrice: string;
      to: string;
      nonce: string;
      data: string;
      value: string;
    };
  }): {
    signedTransaction: string;
  };
  
  signMessage(params: {
    walletType: string;
    path: string;
    message: string;
  }): {
    signedMessage: string;
  };
   
  displayAddress(params: {
    walletType: string;
    path: string;
  }): {
    success: boolean;
  };
}

To get it setup, you do need to do a little scaffolding:

// electron/main.ts
import { app } from 'electron';
import { registerServer } from 'eth-enclave/server';

registerServer(app);
// electron/preload.ts
import { registerProtocol } from 'eth-enclave/preload';

registerProtocol();

and then you're good to go:

import EnclaveAPI, { WalletTypes } from 'eth-enclave/client';

EnclaveAPI.getChainCode({
  walletType: WalletTypes.LEDGER,
  dpath: 'm/44'/60'/0'/0'
}).then(res => {
  // derive addresses from res.publicKey and res.chainCode!
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment