How to store private keys securely in local storage?

I recently started working on a chrome-extension wallet compatible with 4337, you can read more about it here. One of the challenges in creating a wallet is how to store private keys securely in the local storage of the browser.

In this blog, I will be exploring how Metamask & Tally-ho wallet stores their private keys. I hope you and I both will learn during this exploration.

If we want the data to be encrypted with a password & then stored, the best standard to use is AES-FCM. The crypto API provided by the browsers support this by default.

To encrypt a message using AES-FCM we need two things, a key which will be used to encrypt the messages and an initializationVector to add randomness.

How to create a key?

We can use crypto API importkey to generate our master key and then derive the key to encrypt messages for AES-GCM from the master key using deriveKey function. While creating the key we will also generate random bytes salt, the salt must be saved and is needed along with the password to recover the key. Below is the code which generates a new Key if the salt is not passed & recovers the key if the salt is passed.

type SaltedKey = {
  salt: string
  key: CryptoKey
}

async function generateSalt(): Promise<string> {
  const saltBuffer = crypto.getRandomValues(new Uint8Array(64))
  return bufferToBase64(saltBuffer)
}

async function generateOrRecoverKey(
  password: string,
  existingSalt?: string
): Promise<SaltedKey> {
  const { crypto } = global;

  const salt = existingSalt || (await generateSalt())

  const encoder = new TextEncoder();

  const derivationKey = await crypto.subtle.importKey(
    "raw",
    encoder.encode(password),
    { name: "PBKDF2" },
    false,
    ["deriveKey"]
  )

  const key = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: encoder.encode(salt),
      iterations: 1000000,
      hash: "SHA-256",
    },
    derivationKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  )

  return {
    key,
    salt,
  }
}

How to create an initialization vector?

To create our initializationVector we can use the crypto API provided by the browsers.

const initializationVector = crypto.getRandomValues(new Uint8Array(16))

Encrypting text

Now we have generated the key & also initializationVector. We are ready to encrypt our messages. Before encrypting our message, we must encode it using TextEncoder. It takes in the string & emits a stream of UTF-8 bytes.

type EncryptedVault = {
    salt: string
    initializationVector: string
    cipherText: string
}

async function encryptMessage(
    message: string,
    password: string
): Promise<Vault> {
  const encoder = new TextEncoder()
  const encodedPlaintext = encoder.encode(message)

  const { key, salt } = await generateOrRecoverKey(password)
  const initializationVector = crypto.getRandomValues(
      new Uint8Array(16)
  )

  const cipherText = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv: initializationVector },
    key,
    encodedPlaintext
  )
  
  return {
    salt,
    initializationVector: bufferToBase64(initializationVector),
    cipherText: bufferToBase64(cipherText),
  }
}

When we call the above function, encryptMessage, we get three things in return salt, initializationVector, cipherText. We can store all these three values in the browser's localStorage. These values along with the user's password can be used to decrypt the cipherText.

How to decrypt cipher text?

To decrypt the cipherText, we need the three values we stored above along with the user's password. We will first recover the encryption key using the password & salt. After that, we will use the key & initializationVector to decrypt the cipherText. The code below can be used to decrypt


type EncryptedVault = {
    salt: string
    initializationVector: string
    cipherText: string
}

async function decryptCipherText(
  vault: EncryptedVault,
  password: string
): Promise<V> {
  
  const { crypto } = global

  const { initializationVector, salt, cipherText } = vault

  const { key } = await generateOrRecoverKey(password, salt)

  const plaintext = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv: base64ToBuffer(initializationVector) },
    key,
    base64ToBuffer(cipherText)
  )

  return new TextDecoder().decode(plaintext)
}

Usage

Let's round up with the usage of functions we have created above to encyrpt & decrypt a message.

// Encrypting message
const password = "<Some-Strong-User-Password>"

const messageToEncrypt = "Hello, world"

// We can save this vault directly in localstorage & retrieve later when we need to decrypt the stored message
const vault = encryptMessage(messageToEncrypt, password)


// Decrypting message
const descryptedMessage = decryptCipherText(vault, password);

// Validate the messages are equal :)
assert(descryptedMessage === messageToEncrypt)

Reference

Metamask has also released a module which will do all these stuff for you & give you simpler API, check out their module browser-passworder

Usage:

const { strict: assert } = require('assert');
const passworder = require('browser-passworder');

const secrets = { coolStuff: 'all', ssn: 'livin large' };
const password = 'hunter55';

passworder
  .encrypt(password, secrets)
  .then(function (blob) {
    return passworder.decrypt(password, blob);
  })
  .then(function (result) {
    assert.deepEqual(result, secrets);
  });
Subscribe to Garvit Khatri
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.