slowbloom: a journal where the server can't read a word

A look inside slowbloom's zero-knowledge encryption: Argon2id, AES-256-GCM, an envelope-wrapped data key, and a recovery code with no server backdoor.

A journal in a browser window flows into a pink lotus flower with a golden keyhole at its center, dissolving into encrypted blocks that travel to a cloud and server stack.

I built a journal called slowbloom. The pitch is simple: it's a calm, online-first place to write, and every entry is a petal on a flower that fills out the more you show up. That's the part people see.

This post is about the part they don't: I run the server, and I cannot read a single word you write. Not "I promise not to." Not "we have strict access policies." I literally can't — the plaintext never leaves your browser. That's the feature I actually care about, so let me walk through how it works, because the crypto is the most interesting thing in the codebase.

One rule: the server only ever sees ciphertext

Every design decision falls out of a single constraint: the server stores ciphertext and nothing it could use to decrypt it. Your password, your master key, your data key — none of them ever touch the wire. The API receives encrypted blobs and an opaque authentication value, and that's it.

This is the "zero-knowledge" model you've seen from password managers like Bitwarden and 1Password. It's not novel, and that's the point — the worst thing you can do in crypto is invent something. slowbloom is roughly 300 lines on top of the browser's native Web Crypto API, with one well-vetted memory-hard KDF pulled in via WASM. No homegrown primitives. Boring on purpose.

Envelope encryption, end to end

Your password doesn't encrypt your entries directly. Instead there are two keys, and they have very different jobs:

secret ──Argon2id──▶ wrappingKey ──┬─▶ wraps the DEK (stored on server)
                                   └─▶ ─▶ authHash (server auth only)

DEK (random 256-bit) ──AES-256-GCM──▶ encrypts each entry { title, body, … }
  • A random 256-bit data key (DEK) is generated on your device when you sign up. This is what encrypts your entries and images, using AES-256-GCM.
  • Your password is stretched into a wrapping key that does exactly one thing: encrypt ("wrap") the DEK. The server stores only that wrapped blob.

Splitting the keys like this is what makes everything else possible. Changing your password just re-derives the wrapping key and re-wraps the DEK — your thousands of entries are never re-encrypted or exposed. The DEK underneath never changes.

Why Argon2id is the part I'm proud of

The weakest link in any password-based system is the key-derivation function — the thing that turns a human password into key material. If it's fast, an attacker who steals the database can brute-force it on a rack of GPUs.

New slowbloom accounts derive their keys with Argon2id, the winner of the Password Hashing Competition and the current OWASP-recommended default. The parameters are pinned at OWASP's minimum — 19 MiB of memory, 2 iterations, parallelism 1:

const ARGON2_MEMORY_KIB = 19456; // 19 MiB
const ARGON2_TIME       = 2;
const ARGON2_PARALLELISM = 1;

The "memory-hard" property is the whole game. PBKDF2 (the older standard) is cheap to parallelize on a GPU because it barely uses any RAM. Argon2id forces each guess to allocate real memory, which is exactly what GPUs and ASICs are bad at giving you in bulk. It makes mass offline cracking dramatically more expensive.

Older accounts still use PBKDF2-SHA256 at 600,000 iterations (also an OWASP-compliant figure), and they keep working untouched. The detail I'm quietly happy about: the algorithm is stored as a versioned identifier (argon2id, pbkdf2), and that identifier pins the parameters. There's no such thing as a silent parameter change — bumping the cost factor later means a new identifier and an explicit migration, never a guess about what some old row was encrypted with.

Forgot your password? Recover it — without a backdoor

"The server can't decrypt your data" usually comes with an ugly asterisk: forget your password and you're locked out forever. slowbloom softens that without handing the server a master key.

The trick is that the same DEK is wrapped twice — once under your password, and once under a one-time recovery code (five groups of five characters, ~120 bits of entropy, generated on-device). Either secret can unwrap the DEK; neither is derivable from the other; and the server still can't unwrap either one. You can rotate the recovery code from Settings, which invalidates the old one immediately.

The honest tradeoff, straight from the threat model: lose both your password and your recovery code, and your journal is gone. That's not a bug — it's the only design where I genuinely can't read your entries.

The value the server stores can't unlock anything

The server still needs something to authenticate you at login. So after deriving the wrapping key, the client derives a separate auth hash from it (a single PBKDF2 pass with the secret as salt) and sends only that. Crucially, the auth hash is a one-way dead end — you can't run it backwards to recover the wrapping key. And the server doesn't even store it raw; it keeps a bcrypt of it. So a database dump yields a hash of a hash of a key that doesn't decrypt anything.

Even your metadata is mostly encrypted

A lot of "encrypted" apps still leak structure: tags, folders, favorites, flags — all sitting in plaintext columns so the server can sort and filter them. In slowbloom, those live inside the encrypted payload:

interface EntryPlaintext {
  title: string;
  body: string;
  tags?: string[];
  favorite?: boolean;
  archived?: boolean;
  notebook?: string;
  trackers?: Record<string, number | boolean>;
  context?: string;
}

Your tags, your habit-tracker values, your "now playing" context line — the server sees them as the same opaque ciphertext as the entry body.

I'm not going to pretend it's perfect. The server can still see your email, entry timestamps, mood colour, and ciphertext sizes. I'd rather tell you that than claim a clean sweep.

Sharing a single entry, sealed

Sometimes you want to share one entry. slowbloom mints a fresh random key for that share and puts the secret in the URL fragment (the part after #) — which, by web design, browsers never send to the server. Whoever has the link can decrypt that one entry; the server just relays sealed bytes.

What this does not protect against

Every honest security post needs this section, so here's mine:

  • Metadata. As above — email, timestamps, mood colour, sizes are visible to the server.
  • A malicious server. This is the inherent weakness of any web app: I could ship tampered JavaScript that exfiltrates your key. The mitigation is to install the PWA and verify the origin — and, longer term, reproducible builds. I'm not going to wave this away; it's the real limit of browser crypto.
  • Losing both keys. No backdoor means no rescue. By design.

Try it

slowbloom is live at slowbloom.app. Make an account, write a petal, and watch your flower fill out — knowing that the only person who can ever read it is you.

If you spot something wrong in the crypto, I genuinely want to hear it. That's the one part of this I refuse to get cute about.