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.
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,
}
}
To create our initializationVector
we can use the crypto
API provided by the browsers.
const initializationVector = crypto.getRandomValues(new Uint8Array(16))
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
.
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)
}
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)
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);
});