MS

0
Skip to main content

Building a Zero-Knowledge Encrypted Personal Vault

March 25, 2025 (1y ago)

When I started building Simple Life, I had a clear constraint: the server should never be able to read user data. Not because I don't trust servers — I literally built the server — but because zero-knowledge encryption is the only architecture that makes a personal vault genuinely trustworthy.

This post walks through how I designed and implemented zero-knowledge encryption in a full stack app, the tradeoffs I made, and what I'd do differently. If you're building anything that stores sensitive user data, this is relevant to you.

What Zero-Knowledge Encryption Actually Means

Zero-knowledge encryption means the server stores your data but cannot decrypt it. The encryption and decryption happen entirely on the client — the server is essentially a dumb storage layer that holds ciphertext.

This is different from "encryption at rest" (where the server holds the key) or "end-to-end encryption" (which typically implies communication between two parties). In a zero-knowledge vault, there's one party — the user — and the server exists only to sync encrypted blobs across devices.

The implications are significant:

The Encryption Stack

After researching options, I settled on this stack:

AES-256-GCM for Data Encryption

AES-256-GCM (Galois/Counter Mode) provides both confidentiality and authenticity in a single operation. It's the same algorithm used by Signal, 1Password, and most modern encrypted applications.

async function encrypt(plaintext: string, key: CryptoKey): Promise<EncryptedData> {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);
 
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    encoded
  );
 
  return {
    ciphertext: arrayBufferToBase64(ciphertext),
    iv: arrayBufferToBase64(iv),
  };
}

Key decisions here:

PBKDF2 for Key Derivation

The user's master password needs to be converted into a 256-bit encryption key. I use PBKDF2 for this:

async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(password),
    "PBKDF2",
    false,
    ["deriveKey"]
  );
 
  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt,
      iterations: 600000,
      hash: "SHA-256",
    },
    keyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}

I use 600,000 iterations — the OWASP recommendation for PBKDF2-SHA256 as of 2024. Higher iteration counts make brute-force attacks computationally expensive. On a modern phone, this takes about 300-500ms, which is acceptable for a one-time login operation.

The salt is a random 16-byte value generated during account creation and stored on the server. It ensures identical passwords produce different keys for different users.

Architecture: What the Server Sees

Here's the data flow:

  1. User enters master password on client
  2. Client derives encryption key using PBKDF2 + stored salt
  3. Client encrypts data with AES-256-GCM
  4. Client sends ciphertext + IV to server
  5. Server stores the blob — it never sees plaintext or the key

What the server stores for each item:

interface StoredItem {
  id: string;
  userId: string;
  encryptedData: string; // base64 ciphertext
  iv: string;            // base64 initialization vector
  type: "password" | "card" | "document";
  createdAt: Date;
  updatedAt: Date;
}

The type is stored in plaintext — intentionally. The server needs it for filtering and organization, but the actual content (username, password, card number) is encrypted. This is a common tradeoff: metadata is visible, content is not.

Master Key Recovery

The biggest UX challenge in zero-knowledge systems is key recovery. If the user forgets their master password, the data is gone — that's the entire point. But users expect some form of recovery.

My solution: a recovery key generated at account creation.

function generateRecoveryKey(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(32));
  return arrayBufferToHex(bytes)
    .match(/.{1,4}/g)!
    .join("-")
    .toUpperCase();
}

When a user creates an account:

  1. A recovery key is generated on the client
  2. The encryption key is encrypted using a key derived from the recovery key
  3. This "wrapped key" is stored on the server
  4. The user is shown the recovery key once and told to write it down

During recovery, the user enters their recovery key, the client unwraps the encryption key, and the user sets a new master password. The server never sees the recovery key or the unwrapped encryption key.

Migration from Legacy Encryption

Simple Life went through an encryption upgrade — the initial version used a simpler scheme. I had to build an automatic migration that:

  1. Detects legacy-encrypted data on login
  2. Decrypts with the old scheme
  3. Re-encrypts with AES-256-GCM
  4. Updates the server with new ciphertext
  5. Marks the migration as complete

This happens transparently during login. The lesson: plan for encryption upgrades from day one. Cryptographic best practices evolve, and you'll need to migrate eventually.

The Tech Stack

Simple Life is built with:

All encryption happens in the clients using the Web Crypto API. The server is a standard REST API that stores and retrieves encrypted blobs — zero knowledge of what's inside.

For the full stack architecture, I follow the same TypeScript best practices I use across all my projects — shared types between frontend and backend, Zod validation at the API boundary, and Prisma for type-safe database access.

Performance Considerations

Encryption on mobile devices requires careful attention to performance:

These optimizations make the app feel instant despite the encryption overhead.

What I Learned

1. Zero-knowledge constrains every feature decision. Want search? You can't search encrypted data server-side — search must happen client-side after decryption. Want sharing? You need a key exchange protocol. Every feature requires rethinking.

2. Testing encryption is hard. You can't just check if output "looks encrypted." You need to verify decryption produces original plaintext, different IVs produce different ciphertexts, tampered ciphertext fails authentication, and key derivation is deterministic.

3. The UX of security is the real challenge. Explaining "if you lose your recovery key and forget your password, your data is permanently lost" is harder than implementing the encryption itself.

4. Web Crypto API is excellent. Available in all modern browsers and React Native (via polyfills), it provides hardware-accelerated cryptographic operations. No need for external crypto libraries.

Should You Build Zero-Knowledge?

If you're storing sensitive user data (passwords, financial information, health records), zero-knowledge is the right architecture. The implementation complexity is manageable, and the security posture is dramatically better than server-side encryption.

If you're building a general-purpose app where the server needs to read data (for analytics, recommendations, etc.), zero-knowledge doesn't make sense.

The key is deciding early — retrofitting zero-knowledge onto an existing app is significantly harder than building it from scratch.

You can see Simple Life and my other projects on my portfolio. This project was a deep dive into cryptography and security — topics I'll continue writing about on my blog.

If you're on the full stack developer roadmap, understanding encryption patterns is essential knowledge as privacy regulations tighten globally. And for the TypeScript patterns I used across the stack, check out TypeScript full stack best practices.


I'm Manjodh Singh Saran — a full stack developer building production apps with React Native, Next.js, and Node.js. If you're building something that needs serious encryption, feel free to reach out.

Related Articles

MS
Manjodh Singh Saran

Full Stack Developer · Ludhiana, India

Read more articles · View portfolio