Sun Jan 19 2025
Discuss this article on Hacker News.
This is a series of articles about client-side encryption. You are reading part 4.
Table of contents
In the previous article, we introduced the KEK (Key Encryption Key) and we now have a working client-side encryption implementation, even when the user changes their password. There are still some things we could do to improve security though.
We previously decided to store the DEK (serialized as base64) in local storage. A more secure way would have been to store it in the session storage, but I want to avoid my users logging in every time they close the website. Both work, but they have one drawback: we can only store strings. We can’t store binary data or objects directly. This is why we have to export our DEK as a base64 string.
I personally don’t fear XSS attacks on my app because no data is shared between users. But I would like to avoid a different user using the same machine having access to a previous user’s DEK. To do this, we could use IndexedDB.
IndexedDB is an alternative to local storage maintained by the W3C. It’s much more powerful than local storage: it’s a full NoSQL database and can handle way more data. It’s also asynchronous. Since it can store any object (including CryptoKey objects), we can store a DEK in it without allowing any possibility to export it. An attacker would not be able to export the DEK value stored in IndexedDB, which reduces the attack surface.
The big downside of IndexedDB is that its API is really complex and ugly.
Before showing the code, you first need to grasp a few concepts:
onupgradeneeded
event is triggered.Here is what I ended up with:
// We put this beauty in a separate file
// like db.ts
export default class SimpleIndexedDB {
private dbName: string;
private storeName: string;
constructor(
dbName: string,
storeName: string
) {
this.dbName = dbName;
this.storeName = storeName;
}
// Initialize the database
private async initDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(
this.dbName,
// 1 is the version of our database
// If the database doesn't exist, it will be created
1
);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Set an item in the store
async setItem(key: string, value: any): Promise<void> {
const db = await this.initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(
this.storeName,
'readwrite'
);
const store = transaction.objectStore(
this.storeName
);
const request = store.put(value, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Get an item from the store
async getItem<T = any>(key: string): Promise<T | null> {
const db = await this.initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(
this.storeName,
'readonly'
);
const store = transaction.objectStore(
this.storeName
);
const request = store.get(key);
request.onsuccess = () => resolve(
request.result ?? null
);
request.onerror = () => reject(
request.error
);
});
}
// Delete an item from the store
async removeItem(key: string): Promise<void> {
const db = await this.initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(
this.storeName,
'readwrite'
);
const store = transaction.objectStore(
this.storeName
);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(
request.error
);
});
}
}
And here is how you would use it:
// app.ts
import SimpleIndexedDB from './db';
const db = new SimpleIndexedDB('myDatabase', 'myStore');
// Set an item
await db.setItem('foo', foo);
// Get an item
const foo = await db.getItem('foo');
// Delete an item
await db.removeItem('foo');
Since the DEK can be stored as a CryptoKey we can set exportable
to false
when we generate it. Remember our uint8ArrayToCryptoKey
and base64ToCryptoKey
functions?
async function uint8ArrayToCryptoKey(
keyString: Uint8Array,
exportable: boolean = true,
): Promise<CryptoKey> {
return await crypto.subtle.importKey(
'raw',
keyString,
{ name: 'AES-GCM' },
exportable,
['encrypt', 'decrypt'],
);
}
async function base64ToCryptoKey(
keyString: Base64String,
exportable: boolean = true,
): Promise<CryptoKey> {
const raw: Uint8Array = base64ToUint8Array(keyString);
return await uint8ArrayToCryptoKey(
raw,
exportable
);
}
We can now use uint8ArrayToCryptoKey
with exportable
set to false
on user registration:
// ...
const kek: CryptoKey = await generateKek(
userPassword,
kekSalt
);
// DEK creation
const dekValue: Uint8Array = generateDekValue();
const dek: CryptoKey = await uint8ArrayToCryptoKey(
dekValue,
// Here we set exportable to false
false
);
// Encrypting the DEK
const encryptedDek: Uint8Array = await encryptData(
kek,
dekValue
);
And the same thing with base64ToCryptoKey
on login:
// ...
// We get the exported DEK as base64
const dekAsBase64: Base64String = await decryptData(
kek,
dek_iv,
encrypted_dek
);
// We convert it back to a CryptoKey
const dek: CryptoKey = await base64ToCryptoKey(
dekAsBase64,
// Here we set exportable to false
false
);
Then we can directly store and get the DEK as a CryptoKey
object in IndexedDB:
import SimpleIndexedDB from './db';
const db = new SimpleIndexedDB('MyAppName', 'security');
await db.setItem('dek', dek);
const dek: CryptoKey = await db.getItem('dek');
You could also use a third-party library to avoid writing this code to handle IndexedDB, like localForage or Dexie.js.
If you use a third-party library, only use one you can trust and that is actively maintained. This is true in every case but since we’re writing about client-side encryption, it would make even less sense to use a shady JS lib that could steal our user’s data on the client side.
If you’re curious, RxDB published an interesting article comparing the options you have to store things on the client side (covering local storage and IndexedDB among others).
Both localStorage and IndexedDB suffer from the same problem though: there is no way to set an expiration date on the data. Something like this would have been great for us, so that the browser automatically removes from the DB all the stale values. Right now it means if the user doesn’t log out, the DEK will stay in the storage forever. I don’t have any solution for this right now. But storing a non-exportable DEK gives us better guarantees already, in terms of security. If another user uses the same machine once the remember_me
token is invalid, they will not have access to the user’s data anyway. They will be redirected to the login page and we will clear the DEK from IndexedDB.
One thing troubling me is the fact that we still send the password in clear text to the backend. Sure, the backend doesn’t store it, but if an attacker manages to get access to the server and log the incoming requests, they could recreate the KEK of the user (thanks to their salt) and decrypt their DEK.
I thought about a solution which would be to generate a second derived key from the password with a different salt than the one used for the KEK, and to use this as the password for the backend. We would derive two keys from the clear-text password: one to generate the KEK, and one to be used as the password for the backend.
The server would never see the clear-text password of the user. The key received by the backend would be then hashed again on the server (this seems to be known as "Double Hashing ": the client hashes the password, and this hash is then hashed again by the server to compare it to what’s stored in the database).
In a traditional web app, a password has kind of two "states ": clear-text and hashed.
Here we would have three states:
This could look like this:
The problem I had was to find a salt: how do you send their salt to a user on login, before they are authenticated? I decided to pause this idea for now.
Some very smart people already created some protocols to build a system where the server has zero knowledge of the password.
The two most famous ones seem to be SRP (Secure Remote Password) (used by Proton) and OPAQUE. They are both very complex so I didn’t dive into them yet.
I recently read the Bitwarden’s whitepaper though, and they use a zero-knowledge system without SRP or OPAQUE, by using double hashing. It seems way simpler and quite similar to what I had in mind (they use the email as a salt for the password hash on the client side). I would like to dive into this more and I could maybe write about this in the future.
Trying to implement double-hashing made me remember that bcrypt (the password hashing function used by default by some web frameworks) has a limit of 72 characters. As soon as the input reaches 73 characters, the input is silently truncated.
You can easily verify this in TypeScript (with the bcrypt package):
import bycrpt from "bcrypt";
const saltRounds = 10;
// password is 'aaaaa...' (72 characters)
const password = "a".repeat(72);
const hash = await bycrpt.hash(
password,
saltRounds
);
// false (makes sense)
await bycrpt.compare(
"a".repeat(71),
hash
);
// true
await bycrpt.compare(
password,
hash
);
// true too!
await bycrpt.compare(
"a".repeat(73),
hash
);
// true too!
await bycrpt.compare(
"a".repeat(120) + " hello world",
hash
);
I guess this is good to keep in mind if you want to use bcrypt for derived key that can be long (always check their length before choosing the algorithm).
In the first article, I wrote about what I wanted to prevent:
- The first person I want to prevent from accessing data is myself: we all have heard stories about an employee making a mistake, publishing things they didn’t intend to, or (more commonly) storing sensitive information in the logs.
- The second potential threat is a hacker who could get into my server and steal the database.
I guess our implementation only prevents an attacker from reading the data of my users, once they get the database.
Other types of attacks/incidents not being prevented by the current implementation:
We have a (partially-flawed) implementation of client-side encryption. We can improve things further but it’s already quite cool. I learned so much about encryption and the different methods websites use to secure their data! In the future, I would like to explore more about the topic of security.
Writing this series of articles took me quite some time and effort. I would love to get some feedback to understand what’s wrong in my implementation. You can ping me on Twitter, Mastodon or BlueSky.
If you enjoyed it or learned something, don’t hesitate to share it!
Discuss this article on Hacker News.