Hey folks! This issue is about IndexedDB
, WebCrypto
, and my surprising conclusion to the problem of where to put the key in a local-first app.
You can also listen to this issue as a Browsertech Podcast episode:
Historically, one of the disadvantages of browser-based apps over native apps is that they didn’t work if you were offline. These days, browsers provide a way for web apps to be loaded from a cache without an internet connection, using service workers and the file cache API.
This cache generally provides the static assets of the website: HTML, JavaScript bundles, etc., but not user-modifiable application data, like documents.
For storing that mutable application data, apps have a few options:
localStorage
, a string→string key/value mapIndexedDB
, a namespaced object store with indexesOrigin Private Filesystem (OPFS)
, which provides a filesystem-like APIEach has advantages and disadvantages, which are beyond the scope of this post, but as of writing, IndexedDB is probably the best choice for most apps.
Local storage isn’t just for offline apps, it can also make apps faster while online. When loading a document, the application can display the last known local state almost instantly, and then fetch only the parts that have changed from the server.
Regardless of the choice of local storage approach, the data ultimately ends up on the user’s hard drive. This generally leaves it exposed to infostealers, malware that often exfiltrates browser data. For example, a supply-chain attack described by Datadog last month found code hidden in npm
and pypi
libraries to steal cookie data.
To mitigate this type of attack, Chrome implemented a feature this year called app-bound encryption, in which cookies are encrypted on disk using a symmetric key.
The key itself is only available to Chrome by going through a service that runs at elevated permissions. That service is designed to ensure that it only provides the key to Chrome, and not another process, to keep an infostealer from obtaining the key.
Although this approach is not impenetrable in every case, it represents a step forward for cookie security. Microsoft Edge, which is also based on Chromium, has also adopted app-bound encryption.
App-bound encryption only applies to cookie data, not to any of the above-mentioned storage APIs appropriate for larger application data.
Fortunately, browsers provide a good set of cryptographic primitive via WebCrypto.
Once we’ve encrypted the data, we need a place to store the key. Storing it using any of the application data stores (localStorage
, IndexedDB
, or OPFS
) just takes us in a circle — an infostealer could just obtain the key from those off of the disk.
The answer I’ve landed on, to my own surprise, is to save the key in a cookie.
Storing a private key used only by the client in a cookie might not seem intuitive. Cookies are meant for secrets shared with the server, and the server doesn’t have any use for a private key used only for local data. But as of today, it’s the only way for an application to piggyback on app-bound encryption in Chrome and Edge.
For browsers without app-bound encryption, this approach still encrypts the data on disk, but the key ends up also stored as a plaintext cookie. So in these cases, this approach provides little more than a speed bump against an attacker.
Between Chrome and Edge, browsers with app-bound encryption already represent a large share (~75%) of the market, and I expect it to grow as more browsers harden themselves against the rising threat of infostealers.
However, there’s no way to reliably detect whether we are running in a browser with app-bound encryption (which can be disabled). As such, this approach should be considered a best-effort to provide additional protection where plain IndexedDB would be used otherwise, and not as a solution for cases where encryption is a hard requirement.
My deep dive into offline encryption came from building offline encryption into Y-Sweet, our Yjs-based sync engine.
Previously, some people used Y-Sweet in conjunction with y-indexeddb for offline support.
The approach I landed on was inspired by y-indexeddb
’s data layout, which stores individual updates in IndexedDB
as they happen and then compacts them once a threshold number of updates has accumulated. Y-Sweet takes the additional step of applying symmetric encryption (AES-GCM
) to each update as it goes in or out of the database.
The key is base64-encoded and stored in a cookie. In browsers that support app-bound encryption, that cookie is encrypted on disk. To decrypt the data on disk, an attacker would first need to get the browser’s master key (which is guarded by a privileged process) to decrypt the Y-Sweet key, and then use the Y-Sweet key to decrypt the IndexedDB.
WebCrypto and WebAuthn have provided some great primitives for browsers to work with, but secure offline key storage remains to be a missing piece of the puzzle. If browsers had an API for secure secret storage, it would be a huge boon to developers of local-first software who want to encrypt data on the client at rest.
I imagine this being a localStorage
-like API (maybe called localSecretStorage
), with encryption on disk using app-bound encryption. One benefit over cookies would be to provide an affirmation to the application that the contents are are in fact stored securely, so the application could make decisions on what to store accordingly. It would also enable applications to store secrets that are not shared with the server, and has a nicer JavaScript API than cookies.
I hope we get there some day. For now, against all intuition, if infostealers are the threat you care about, encrypting local storage and storing the key in a cookie appears to be the best approach.