← All work

Sole engineer · 2026 · live

Lettler

Zero-knowledge encrypted message sharing for people handling client data.

  • React 18
  • Vite 5
  • Mantine 7
  • GSAP
  • Node 20
  • Express
  • Cloud Run
  • Firestore
  • Firebase Auth
  • Web Crypto
  • Stripe

The problem

Sending a password or a client record over email or Slack leaves a permanent copy on infrastructure you do not control, readable by anyone who later gains access to the mailbox. The usual fix — a "secure share" service — just moves the plaintext to a different company's database.

What I built

A send-a-secret and request-a-secret service where the server never sees plaintext. The browser encrypts before upload; the decryption key lives only in the URL fragment, which is never transmitted in an HTTP request. The inverse flow generates an ECDH keypair so a stranger can encrypt to your public key without an account — you send an intake link, they send you something only you can open.

The API is Node and Express on Cloud Run, with Firestore behind it, Firebase Auth in front of the account surface, and Stripe for tiers. The frontend is React and Vite with Mantine and GSAP.

Decisions

Firestore locked server-only. Rules deny all client reads and writes — allow read, write: if false — so every request goes through Cloud Run. This is what makes burn-after-reading, expiry, and quotas actually enforceable rather than advisory: a rule a client could route around is not a rule. Given up: the Firebase SDK's realtime client reads, and any possibility of a frontend-only build. Every read is now a round trip I have to write, test, and pay for.

Rate limiting in Firestore instead of memory. Per-instance counters reset whenever Cloud Run scales out, which makes them decorative — an attacker who can trigger a cold start gets a fresh budget. A transactional Firestore store holds limits across instances. The cost is one transactional read and write on every limited request, paid on the happy path as much as the abusive one.

Expiry clamped, not rejected. The client computes an expiry from its own clock and asks for exactly the ceiling its tier allows. A few seconds of clock skew would otherwise surface to the user as a validation error on a request that was honest, so the server clamps the value down instead of refusing it. Given up: strictness. The server no longer tells a client its clock is wrong; it silently corrects and continues.

Hard part

Adding a password to browser-side encryption usually means swapping the URL key for a password-derived one. That quietly changes the threat model: the link alone no longer protects anything, and the password becomes a single point of failure, guessable offline by anyone holding the ciphertext.

I layered instead of swapping. The message is encrypted with AES-GCM-256 under the URL key, and that entire payload is then re-encrypted under a PBKDF2-HMAC-SHA256 key at 600,000 iterations. Both are required. Losing either one leaves an attacker with nothing.

That left a second problem, and it is the one that took the longest. The server has to reject wrong passwords without learning anything useful, because a wrong guess must not burn a one-time message — but the server holds only ciphertext, so it has nothing to check a guess against. The answer was a second PBKDF2 output derived from the same password with a different domain separation string. That output is a verifier and nothing else: the server stores only its SHA-256 and compares in constant time. It is enough to gate access and useless for decryption, because the value that unlocks the payload is derived under a different domain and never leaves the browser. The effect is that offline guessing — unlimited, parallel, free — becomes online guessing against a rate limiter.

Reading is then split into two operations rather than one. peek returns metadata and never burns anything, so a recipient can see that a message exists and what it will cost them to open it. consume checks the verifier before any destructive branch, inside a transaction, so the delete and the authorization can never interleave. Getting that ordering wrong in either direction is a real bug: check after the delete and a wrong password destroys someone's message; check outside the transaction and two concurrent reads can both win.

Vault custody sits on top. A stored private key is wrapped with a key derived from a WebAuthn PRF-extension secret, so the unwrapping material lives in the authenticator rather than in anything I store or transmit.

Outcome

Live at lettler.com. Thirty test files, including Firestore rules tests run against the emulator, so the server-only lockdown is verified as a behavior rather than assumed as a configuration. Production deploys are gated on three parallel CI jobs behind a deploy concurrency group.