antonta's space
Following patterns wherever they emerge.
main posts

Hacky security

If you've been dealing with web apps you'd have likely stumbled upon this question. How do I make sure the secrets are stored securely in the browser context? I have a solution for you, the idea for which was actually given by a friend.

I'm not going to explore many options, as they typically break down under scrutiny. Instead I will just share the solution. I'm not going to describe too much of the technical details, just cover it on a high level. I expect you are smart enough to figure out how to actually implement that.

Passkeys!

Yes, these little FIDO-compatible hardware keys. They are typically used for authentication, but that's not what we are interested in. We are interested in symmetric encryption of local storage data. It is possible to abuse these keys exactly for that, just requires using experimental extensions. One of these is PRF.

So you can get key material via PRF and use that as a key and then encrypt off that. That's it?

Convoluted flow

Turns out it is not as simple to implement. In my E2EE app this is one of the most complex flows out there. If you start taking into account multi-tab synchronization, an intermediate state when secrets are in memory and haven't been written to local storage pending user decision, the global nature of the flow, as the keys are required by all downstream operations, pending state during window reload, decryption failure handling. Maybe I missed something.

To start off, we need to store some state about local storage encryption - I will call it LSE later on. At the minimum, we need to know the key off which the data is encrypted, whether the encryption is enabled or not, and a salt for PRF. Here's the basic state we need to remember in local storage:

interface LSEncryptionInfo {
    // Whether the local storage encryption is enabled.
    enabled?: boolean;
    // The passkey used to encrypt the secrets.
    passkey?: Passkey;
    // Salt for the PRF extension.
    // Has to be stable for the duration of the session.
    salt?: string;
}

interface Passkey {
    // ID of the non-resident key.
    id: string;
    // Transports for better UX.
    transports: AuthenticatorTransport[];
}

Initial load

First thing during the app load, get that LSEncryptionInfo off the local storage and save it somewhere in memory, quick! First decision split: if LSE is disabled, attempt to read the secrets from local storage in plain-text. If the key has not been derived yet, and LSE is enabled, generate one using the salt stored. Handle errors of misconfiguration here too - enabled LSE, yet Passkey or salt are missing.

Generating a new encryption key will prompt the user to tap the hardware key. Meanwhile the UI should show something like a loading state, at least that's what I chose, so that intermediate state is communicated through the UI.

If PRF is rejected for some reason, say due to timeout or missing key, capture the error and display to the user an option to attempt again or logout, since the secrets cannot be decrypted.

On success, the secrets are decrypted from the local storage and put into memory, followed by the normal application load process.

Here, also, if secrets have expired or not present, probably go through the logout process and display a login page to the user.

Login and logout

Past the initial load, if the user is redirected to login (or at login page already), they normally enter their credentials, the secrets are presumably derived and are held in memory only. The UI, in my case, displays a dialog to either encrypt the local storage or skip it entirely. When selecting to encrypt, we have yet another decision to make. Passkey ID present in LSEncryptionInfo, then use that, if not, generate a new salt and ask a user to create a non-resident public key and store that in localstorage. Flip the flag to enabled. Skipping the encryption should just set the flag to false, and persist that decision.

What happens if the user reloads the page during this dialog? I chose to just "clear" the memory (not that you can do that reliably in the browser) and let the user start over.

All good so far. You just need to wait for an appropriate event and write the secrets to local storage, either encrypted or plain-text.

Logout is not hard - clear the secrets from local storage and drop the enabled flag.

Accessing encrypted local storage

It is just a matter of having extra logic in the loading and persisting parts of secrets through local storage. Be careful, however, that the app may be in some intermediate state here. For example, if the LSE is enabled, but the encryption key has not been derived yet. Logic becomes quite complicated, but manageable. Think of it as passing the operation through an additional filter that decides whether to use plain-text or encrypted data, handling the errors appropriately.

At this stage, the whole process may fail with decryption error. How to handle that? I decided to put a UI element which shows the decryption error and gives an option to retry or logout.

Multi-tab synchronization is not that complex, you just have an extra state to keep track of - that annoying LSEncryptionInfo. Moreover, you can also get secret decryption errors here. Maybe I lied about it not being too complex. The number of conditions per function is quite large.

The horrors

The whole thing turned out to be event-driven, dense in logic, a bunch of code split across UI and state management layers, operating at the initial application load. I spent 2-3 iterations refining that, and still not happy with the result. This is the part that may benefit from a clear diagram of the flow, as it is hard to reconstruct all this event handling and complex logic from the code alone if you don't already know what you are dealing with. And if you need a diagram for your typical code, the code is likely garbage or has enterprise feel to it. Introducing bugs is also quite easy.

Do I recommend that? I don't know. On one hand the complexity is very high, on the other hand you get a truly secure storage environment right there in the browser. If you use Passkeys for key management and also encrypt these keys in local storage off the same Passkey device, you get a really nice flow from the user's perspective with strong security guarantees. I'm not going to drop that functionality in my app at least - the sunk cost fallacy is real.