Invisible Keys Snap: Multi-cloud private key storage

An ETHLisbon Hackathon winner

by MetaMaskDecember 21, 2022
invisible keys

MetaMask Snaps is the roadmap to making MetaMask the most extensible wallet in the world. As a developer, you can bring your features and APIs to MetaMask in totally new ways. Web3 developers are the core of this growth and this series aims to showcase the novel MetaMask Snaps being built today.

Invisible Keys Snap

Why did you build it?

Externally-owned accounts are at the core of virtually every dapp or service in the Ethereum ecosystem. However, it is far from trivial to design and implement a wallet application that is both secure and easy to use. Typically, in order to provide full control to the user and high levels of security, we end up resorting to complex and unpractical mechanisms. On the other hand, straightforward and enjoyable user experience is only achieved by asking users to surrend control over their keys.

With InvisibleKeys, we built on MetaMask’s renowned user experience to propose a new compromise between user experience and security allowing MetaMask to connect to an innovative external multi-cloud wallet.

How? InvisibleKeys’ multi-cloud wallet stores the user’s private keys in two or more cloud storage services (e.g. Google Drive, Dropbox, etc) in such a way that even if one of these services is compromised the keys never are. This multi-cloud approach follows a patented workflow (patent US20190095628), which ensures very high levels of security while allowing multi-device synchronisation.

To import an account from the cloud, MetaMask reads a JSON file in your Google Drive (or any other storage service) with an array of your public keys. Then, to sign a transaction, your private key (PK) is temporarily and locally reconstructed at an external web app that receives MetaMask’s requests for signature, signs the transaction, and returns the signed transaction to MetaMask. The keys are never persisted and only exist temporarily in memory. In addition, to preserve full user control, the PK is never accessed by the MetaMask extension. Even if the wallet is unlocked or compromised, the PK cannot be stolen.

InvisibleKeys allows users to access their wallet from anywhere in the world, as everything is stored in the cloud, complementing the traditional MetaMask approach and maintaining MetaMask’s high standards for usability and UI interface.

Architecture

InvisibleKeys works with two main components: the MetaMask wallet and the external web app.

As we intend to sign the transaction outside of the wallet, we took advantage of the hardware wallet connection/signing part of the code. Instead of sending the transaction to be signed to a specific hardware, a popup is opened with our web app where the signing will be executed.

In the web app, the user authenticates with its cloud credentials, the private key is retrieved, the transaction is signed and the key is erased from memory. Finally, the popup is closed and the user is back to the MetaMask wallet which was in a waiting state, expecting a signed transaction to be sent.

The two components communicate through the browser’s window web API. This is enough in this context since no private information is ever exchanged between the two.

UI Changes

Firstly, we had to make some UI changes to MetaMask to add our InvisibleKeys connection strategy. It is added the same way a new supported hardware wallet would be added to the app.

These are some of the changes made to the code. Added a new device and type, and the new button for the UI.

app/scripts/metamask-controller.js

keyringName = InvisibleKeyring.type;
break;

ui/pages/create-account/connect-hardware/select-hardware.js

  renderConnectToInvisibleButton() {
    return (
      <button
        className={classnames('hw-connect__btn', {
          selected: this.state.selectedDevice === 'invisible',
        })}
        onClick={(_) => this.setState({ selectedDevice: 'invisible' })}
      >
        <img
          className="hw-connect__btn__img"
          src="images/invisible-logo.png"
          alt="Invisible"
        />
      </button>
    );
  }

Invisible Keys Keyring

To connect different hardware wallets, the Keyring class acts as an interface to communicate between the wallet and the brand’s device. The main methods needed to import accounts and sign transactions are: addAccounts() which returns an array of addresses, and signTransaction(), which returns a signed transaction.

We’ve implemented these methods on InvisibleKeyring. The first one opens the popup and expects a message with an array of addresses. The second one opens the popup with the unsigned transaction encoded in a message. Then, expects an encoded signed transaction, which will be returned and the main controller will send. When waiting for the closure of the popup, the Metamask is in a loading state.

These methods make calls to a different class InvisibleConnect which is responsible for the web app connection and communication.

./InvisibleConnect.js

export default class InvisibleConnect {

  {...}

  const appUrl = 'http://url-to-web-app';

  async getAccountsCloud() {
    let newAccounts = []

    //create a listener for the imported accounts
    window.addEventListener(
      'message',
      event => {
        if (event.data.event_id === 'imported_accounts') {
          newAccounts = event.data.accounts
        }
      },
      false,
    )

    return new Promise(async resolve => {
      const child = window.open(appUrl + '/import')
      const interval = setInterval(() => {
        if (child.closed) {
          //when the popup is closed
          clearInterval(interval)
          this.accounts = newAccounts
          resolve(newAccounts)
        }
      }, 1000)
    })
  }

  async signTxCloud(transaction) {
    let signedTx = {}

    //create a listener for the signedTx
    window.addEventListener(
      'message',
      event => {
        if (event.data.event_id === 'signedTx') {
          signedTx = {
            v: event.data.v,
            r: event.data.r,
            s: event.data.s,
          }
        }
      },
      false,
    )

    return new Promise(async resolve => {
      const child = window.open(appUrl + '/sign') //open popup

      child.postMessage(
        //sends message to the popup
        {
          event_id: 'unsignedTx',
          data: {
            tx: transaction.serialize().toString('hex'),
          },
        },
        '*',
      )

      const interval = setInterval(() => {
        if (child.closed) {
          //when the popup is closed
          clearInterval(interval)
          resolve(signedTx)
        }
      }, 1000)
    })
  }
}
./eth-invisible-keyring.js

import InvisibleConnect from './InvisibleConnect'

class InvisibleKeyring extends EventEmitter {

  {...}

  addAccounts(n = 1) {
    return new Promise((resolve, reject) => {
      this.unlock()
        .then(async _ => {
          const from = this.unlockedAccount
          const to = from + n
          this.accounts = []

          for (let i = from; i < to; i++) {
            const address = InvisibleConnect.getAccounts(i).address

            this.accounts.push(address)
            this.accountIndexes[ethUtil.toChecksumAddress(address)] = i
            this.page = 0
          }
          resolve(this.accounts) //resolve the new accounts
        })
        .catch(e => {
          reject(e)
        })
    })
  }

  signTransaction(address, tx) {
    return new Promise(async (resolve, reject) => {
      try {
        const signedTx = await InvisibleConnect.signTxCloud(tx)
        const txData = tx.toJSON()
        txData.v = ethUtil.addHexPrefix(signedTx.v)
        txData.r = ethUtil.addHexPrefix(signedTx.r)
        txData.s = ethUtil.addHexPrefix(signedTx.s)

        const common = tx.common
        const freeze = Object.isFrozen(tx)
        const feeMarketTransaction = FeeMarketEIP1559Transaction.fromTxData(
          txData,
          {common, freeze},
        )
        resolve(feeMarketTransaction)
      } catch (err) {
        reject(new Error(err))
      }
    })
  }

   {...}
}

External App

The app has two pages: one where the accounts are imported and the other where the transaction is signed.

In the first one, the user authenticates to google drive where a file with its public keys is stored in metamask/public.json. Then, after clicking the import button, the file is fetched, and the keys are read and sent through the window web API to the MetaMask wallet.

const sendAccountsToMetamask = (publicKeys: Address[]) => {
	window.opener.postMessage(
	  {
	    event_id: 'importAccounts',
	    data: {
	      accounts: publicKeys,
	    },
	  },
	  '*'
	);
};

const getPublicKeys = async (): void => {
     const publicKeys: Address[] = await getGoogleDrivePublicKeys();
     sendAccountsToMetamask(publicKeys);
     return;
};

In the second one, the user has to authenticate to both Google Drive and Dropbox, which were the services we used for this example. The approach is compatible with any storage service. The requirement being that they are non-colluding, independent services. After authentication, the different parts of information are retrieved from those services and the private key is reconstructed using the decrypt() method, which is implemented according to the mechanism described in the aformentioned patent, and the transaction is signed. Immediately after that, the private key is erased from memory. Finally, the signed transaction is sent back to the MetaMask wallet.

Here is the code step by step. Only relevant parts of the code are shown. Everything related to the UI state or API communication is omitted.

const sendTxToMetamask = (v, r, s) => {
	window.opener.postMessage(
	  {
	    event_id: 'signedTx',
	    data: {
	      v: v,
	      r: r,
	      s: s,
	    },
	  },
	  '*'
	);
};

const signTransaction = async (): void => {
     const trs = TransactionFactory.fromSerializedData(Buffer.from(tx, 'hex'));
     const googleDrivePart = await getGoogleDrivePart();
     const dropboxPart = await getDropboxPart();

     const piecesArray = [
         Buffer.from(googleDrivePart, 'hex'), 
         Buffer.from(dropboxPart, 'hex')
     ];
     const decrypted = decrypt({ data: piecesArray });
     let plainTextPK = Buffer.from(
         new TextDecoder().decode(decrypted.data),
         'hex'
     );

     if (!ethUtil.isValidPrivate(plainTextPK)) {
        alert('Failed to decrypt Private Key');
        return;
     }

     const signedTx = trs.sign(plainTextPK);
     plainTextPK = null; //erase from memory

     const v = ethUtil.stripHexPrefix(ethUtil.bufferToHex(signed_tx.v));
     const r = ethUtil.stripHexPrefix(ethUtil.bufferToHex(signed_tx.r));
     const s = ethUtil.stripHexPrefix(ethUtil.bufferToHex(signed_tx.s));

     if (signed_tx.verifySignature()) {
         sendTxToMetamask(v, r, s);
     } else {
         alert('Signature failed');
     }
    return;
}

Can you tell us a little bit about yourself and your team?

We are a emthusiastic team of software engineers based in Braga, Portugal, with strong background on distributed systems. We created Invisible Lab, a startup focused on solving hard problems in the blockchain and web3 space.

When were you first introduced to MetaMask Snaps and what was your experience like?

The first time we were introduced to MetaMask Snaps was on the launch of the SDK. Since then, the technology has improved significantly and continues to do so.

What makes MetaMask Snaps different from other wallets?

MetaMask Snaps wallet provides the ability to build features on top of it while maintaining a design that millions of users already interact with every day.

Tell us about what building Snaps with MetaMask is like for you and your team?

We’ve used a preliminary version of MetaMask Snaps, which did not support every feature we needed for this project. However, we believe everything we built will be possible to build with Snaps in the near future, in a much simpler and smoother way.

What does MetaMask Snaps mean to you?

It means customization, upgradeability and a deluge of possibilities to build innovation on top of using the Metamask Wallet.

What opportunities do you see with MetaMask Snaps and the possibilities it unlocks for the Web3 space?

One amazing opportunity we see and find interesting is to build additional security strategies. Creating different levels of security depending on the experience and level of risk that each user want to be exposed to. This may have a significant impact on reducing user exposure to security issues and technology hacks that unfortunately have been common in this space. Mitigating these issues is crucial to the mass adoption of web3.

Any advice you would like to share with developers keen to try MetaMask Snaps?

Don’t be afraid to dig in! You’ll be surprised by what you can accomplish with MetaMask Snaps. Either by integrating new blockchains, features, or security approaches!

Building with MetaMask Snaps

To get started with MetaMask Snaps:

  1. Checkout the developer docs
  2. Install MetaMask Flask
  3. Check out the MetaMask Snaps guide
  4. Stay connected with us on Twitter, GitHub discussions, and Discord

Keep an eye out for our team at the next hackathon in an area near you! Happy BUIDLing ⚒️

Disclaimer: MetaMask Snaps are generally developed by third parties other the ConsenSys Software. Use of third-party-developed MetaMask Snaps is done at your own discretion and risk and with agreement that you will solely be responsible for any loss or damage that results from such activities. ConsenSys makes no express or implied warranty, whether oral or written, regarding any third-party-developed MetaMask Snaps and disclaims all liability for third-party developed MetaMask Snaps. Use of blockchain-related software carries risks, and you assume them in full when using MetaMask Snaps.

Receive our Newsletter