nimimo Logonimimo
All articles
·4 min read

Why We Build on WebCrypto

A JavaScript crypto library hands you the key. The browser's own crypto refuses to.

technicalencryptionsecurity

There is a question every browser-based wallet has to answer, and most answer it badly: when your encryption key exists inside a web page, what stops the web page from reading it?

With a JavaScript crypto library, nothing does. The key is a byte array in memory. Your code can read that array. So can a dependency's code, and so can a compromised script from a CDN, because reading arrays is what JavaScript does. The library cannot stop it. It does not have the vocabulary to.

nimimo uses WebCrypto instead, and the reason is one boolean.

The boolean

WebCrypto does not give you keys. It gives you handles to keys. When you derive a key, you specify whether it may ever be exported back into readable bytes, and nimimo says no:

return window.crypto.subtle.deriveKey(
  {
    name: "PBKDF2",
    salt: deviceSalt,
    iterations: 600000,
    hash: "SHA-256",
  },
  keyMaterial,
  { name: "AES-GCM", length: 256 },
  false,              // extractable, so the key can never be read back
  ["encrypt", "decrypt"],
)

That `false` is the whole argument. The returned object is a CryptoKey, and a CryptoKey is a reference the browser honours, not data your code holds. You can pass it to encrypt. You can pass it to decrypt. You cannot print it, serialize it, put it in a fetch body, or accidentally log it during a debugging session at two in the morning.

Calling exportKey on it does not return the bytes. It throws. The restriction is enforced by the browser, below the JavaScript engine, where page code has no reach.

What this buys you

The threat this addresses is not a cryptographer breaking AES. It is far more ordinary: some code on the page that should not have your key gets your key anyway.

  • A supply-chain compromise in a dependency that runs in the same context as your wallet code
  • An injected script that survives a gap in the content security policy
  • A well-meaning error reporter that serializes local variables into a crash report
  • A logging line that was supposed to be temporary
  • A browser extension with permission to read page state

In every one of those, an extractable key is a readable key. A non-extractable key is a handle that does nothing useful outside the page's own security context. The attacker can ask the browser to decrypt while they are on the page; they cannot take the key home with them.

That is a meaningful downgrade in blast radius. It converts a permanent compromise into a temporary one.

Deriving, not storing

The second thing WebCrypto gets right is that the key is derived at use time rather than stored. What lives in IndexedDB is not a key. It is 32 bytes of random key material and, separately, 32 bytes of random salt. Two independent values, neither of which is an encryption key on its own.

The actual key is produced from those through PBKDF2-SHA256 at 600,000 iterations, and exists only as a non-extractable handle for as long as it is needed. There is no moment at which a complete, usable key sits at rest in storage waiting to be copied.

600,000 iterations is not there to slow down an attacker who has the key; they would not need to derive it. It is there for the case where someone has the stored material and is guessing at the rest. The cost is paid once per session by you, and once per guess by them.

The same primitive, a different input

Recovery cards use the identical construction with one substitution: instead of device-bound random material, the input is the PIN you choose. Same PBKDF2-SHA256, same 600,000 iterations, same AES-GCM at 256 bits, same non-extractable result.

The symmetry is deliberate. Two unlock paths, this device or this PIN, built from one primitive, so there is one implementation to audit rather than two. The difference between daily use and disaster recovery is which secret goes in the front, not which cryptography runs behind it.

What it does not solve

Non-extractable keys protect the key. They do not protect the plaintext. While your wallet is unlocked and in use, the decrypted mnemonic is an ordinary string in ordinary memory, and code running on the page at that moment can read it.

This is why the boolean is a layer and not a solution. It sits underneath a content security policy with a per-request nonce, dependencies that get pinned and reviewed, and a decrypted-state window kept as short as the operation allows. WebCrypto makes the key hard to steal. Everything else is about making the plaintext rarely present.

We chose the browser's crypto over a library because a library can only promise not to leak your key. The browser can refuse to hand it over. When you are building something whose entire premise is that we cannot betray you, a refusal enforced below your own code is worth more than a promise made inside it.

Ready to try it?

No seed phrases. No KYC. Just an email.

Read the audit