DEV Community

Typelets Team
Typelets Team

Posted on • Edited on

How We Built Zero-Knowledge Encryption in the Browser

How We Built Zero-Knowledge Encryption in the Browser (So We Can't Read Your Notes)

Most note-taking apps claim to be "encrypted" but still have a dirty secret: they can read all your data. When we built Typelets, we decided to do things differently - using true zero-knowledge encryption where even we can't decrypt your notes.

Here's exactly how we did it using just the Web Crypto API.

The Problem That Made Us Build Typelets

When apps like Notion say they're encrypted, they usually mean:

✅ HTTPS encryption in transit
✅ Database encryption at rest
They still hold the keys to decrypt everything

We wanted to build a notes app where privacy wasn't just a marketing claim - it was mathematically guaranteed. That's why we created Typelets with zero-knowledge architecture.

How Typelets' Zero-Knowledge Encryption Works

In Typelets, here's what happens when you create a note:

🔐 Data encrypted on your device before transmission
🔑 Encryption keys never leave your browser
🚫 We literally cannot decrypt your data
🛡️ Even if our servers get hacked, attackers get useless encrypted blobs

Implementation: The Technical Details

Here's the actual code powering Typelets' encryption. Every user must set a master password that never leaves their device:

1. Master Password Setup

async setupMasterPassword(masterPassword: string, userId: string): Promise<void> {
  // Create user-specific salt
  const userSalt = `typelets-salt-${userId}-v1`

  const encoder = new TextEncoder()
  const passwordBytes = encoder.encode(masterPassword)
  const saltBytes = encoder.encode(userSalt)

  // Import password as key material
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    passwordBytes,
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  )

  // Derive encryption key from master password
  const encryptionKey = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: saltBytes,
      iterations: 250000, // High iteration count for security
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  )

  // Store derived key locally in browser (never sent to server)
  await this.storeKeyInSecureStorage(userId, encryptionKey)
}
Enter fullscreen mode Exit fullscreen mode

Critical security feature: The master password never leaves your browser. Even if someone knows your userId, they cannot decrypt your notes without your password.

2. Key Derivation on Unlock

async unlockWithMasterPassword(
  masterPassword: string,
  userId: string
): Promise<boolean> {
  // Create user-specific salt (same as setup)
  const userSalt = `typelets-salt-${userId}-v1`

  const encoder = new TextEncoder()
  const passwordBytes = encoder.encode(masterPassword)
  const saltBytes = encoder.encode(userSalt)

  // Re-derive the key
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    passwordBytes,
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  )

  const derivedKey = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: saltBytes,
      iterations: 250000,
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  )

  // Verify by attempting to decrypt a test note
  // If successful, cache the key for the session
  return await this.verifyKey(derivedKey, userId)
}
Enter fullscreen mode Exit fullscreen mode

3. How Typelets Encrypts Your Notes

// This runs in your browser when you save a note in Typelets
async encryptNote(
  userId: string,
  title: string,
  content: string
): Promise<EncryptedNote> {
  // Get encryption key (derived from master password)
  const key = await this.getEncryptionKey(userId)

  // Generate random IV for this note (ensures same content encrypts differently)
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const encoder = new TextEncoder()

  // Encrypt title and content separately
  const titleBytes = encoder.encode(title)
  const contentBytes = encoder.encode(content)

  const encryptedTitleBuffer = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    titleBytes
  )

  const encryptedContentBuffer = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    contentBytes
  )

  return {
    encryptedTitle: this.arrayBufferToBase64(encryptedTitleBuffer),
    encryptedContent: this.arrayBufferToBase64(encryptedContentBuffer),
    iv: this.arrayBufferToBase64(iv)
  }
}
Enter fullscreen mode Exit fullscreen mode

4. What Typelets' Database Actually Stores

Our PostgreSQL database only ever sees encrypted data:

// What Typelets stores (completely encrypted)
notes: {
  id: "note_123",
  encrypted_title: "kJ9fX2zR8vQ...", // Encrypted blob
  encrypted_content: "mP4sL7nE1wY...", // Encrypted blob
  iv: "aB3dF6gH9jK...", // Initialization vector (safe to be public)
  user_id: "user_456" // Only metadata we can see
}

// What Typelets NEVER sees
{
  title: "My secret thoughts", //  Never stored in plaintext
  content: "Today I...",        //  Never stored in plaintext
  master_password: "...",       //  Never leaves your device
  encryption_key: "..."         //  Never leaves your device
}
Enter fullscreen mode Exit fullscreen mode

Addressing Security Concerns

Q: "If you use userId as input to key derivation, can't anyone generate the same key?"

Great question! This was actually a concern with our earlier implementation. Here's how we've addressed it:

Current implementation:

  • Key derivation: PBKDF2(masterPassword, userSalt) with 250,000 iterations
  • User-specific salt: typelets-salt-${userId}-v1 (unique per user, but public)
  • Secret component: The master password that ONLY the user knows

Why this is secure:
Even if an attacker knows:

  • ✅ The userId
  • ✅ The salt formula
  • ✅ The iteration count
  • ✅ The algorithm (PBKDF2-SHA256)

They cannot decrypt without the user's master password. The salt prevents rainbow table attacks, but the master password is what provides the actual security.

Result: True zero-knowledge architecture where we literally cannot access user data, even if compelled by law.

Security Considerations

The Good

  • Unbreakable privacy: AES-256-GCM with 250,000 PBKDF2 iterations
  • Perfect forward secrecy: Each note has unique IV
  • No server-side keys: We can't decrypt even if legally compelled
  • Browser security: Leverages battle-tested Web Crypto API
  • Zero-knowledge verified: Mathematical guarantee of privacy
  • No password recovery: By design - we never see your password

The Tradeoffs

  • Lost password = lost data: If you forget your master password, your notes are permanently inaccessible
  • No server-side search: We can't index encrypted content (client-side search only)
  • Limited analytics: We can't analyze usage patterns in content
  • Session re-authentication: Must re-enter password after browser restart
  • Master password requirement: Users must remember a strong password

Why No Password Recovery?

This is intentional. If we could recover your password, we could decrypt your data - breaking the zero-knowledge guarantee. True privacy means accepting this tradeoff.

Performance Impact

Surprisingly minimal:

  • Encryption time: ~2ms for typical note (1000 chars)
  • Key derivation: ~500ms with 250k iterations (only on unlock/setup)
  • Decryption time: ~1ms per note
  • Bundle size: 0 bytes (uses native Web Crypto API)
  • Battery impact: Negligible on modern devices

The 250k PBKDF2 iterations only run when setting up or unlocking (once per session), so day-to-day usage is extremely fast.

Why We Built Typelets This Way

In an era where AI companies train on user data and governments demand backdoors, we wanted to create a notes app that was truly private by design.

The result? Even under legal pressure, we can only hand over encrypted blobs that are mathematically useless without the user's master password.

Real-World Implementation

This isn't theoretical - you can see this encryption in action:

  1. Visit app.typelets.com
  2. Create an account and set your master password
  3. Open your browser's DevTools → Network tab
  4. Create a note and watch the request payload
  5. You'll see only encrypted data being transmitted

Here's what an actual API request looks like:

POST /api/notes
{
  "encryptedTitle": "kJ9fX2zR8vQmP4sL7nE1wY...",
  "encryptedContent": "aB3dF6gH9jKmN8pQ2rT5vX...",
  "iv": "yZ1cE4fG7hI0jL3mN6oP9q..."
}
Enter fullscreen mode Exit fullscreen mode

Our server literally cannot read what you're saving.

Try Typelets' Zero-Knowledge Encryption

You can experience this encryption in action at app.typelets.com. Open your browser's dev tools and watch the encryption happen client-side - it's pretty cool to see your data get scrambled before it leaves your device.

We've also published our security architecture at typelets.com/security if you want to dive deeper into the technical details.

Building Your Own Zero-Knowledge App?

The Web Crypto API makes client-side encryption surprisingly straightforward. The hardest part isn't the crypto - it's designing your entire app architecture around never seeing user data.

Key lessons we learned:

  1. Always require a user secret - Don't rely on userId or derivable values alone
  2. Make salts user-specific - Even though they're public, they prevent rainbow tables
  3. Use high iteration counts - 250k+ PBKDF2 iterations slow down brute force attacks
  4. Never transmit the master password - It should never leave the user's device
  5. Accept the tradeoff - No password recovery means permanent data loss if forgotten
  6. Session key caching - Keep the derived key in memory during the session for performance
  7. Clear documentation - Users need to understand the implications of zero-knowledge

Security checklist for zero-knowledge apps:

  • [ ] Master password never sent to server
  • [ ] Encryption happens client-side before transmission
  • [ ] Server stores only encrypted data
  • [ ] Decryption only possible on client
  • [ ] User-specific salts (even if predictable)
  • [ ] High PBKDF2 iteration count (250k+)
  • [ ] Clear warning about data loss if password forgotten

Questions for the community:

  • How do you handle encryption in your apps?
  • What other zero-knowledge services do you use?
  • How do you balance security vs. user convenience?

Drop a comment - I'd love to discuss the technical challenges and tradeoffs of privacy-first development!


Want to see the full implementation? Check out our GitHub repo or explore the security architecture docs.

Top comments (2)

Collapse
 
mark_sargent_929c3b870d9b profile image
Mark Sargent

Thanks for sharing.

If I read this correctly, you have a static salt and the input to deriveUserKey is the userId, quite likely this value is public and difficult to change. What would prevent me generating an identical private key for any userId, and using that to decrypt the content?

Collapse
 
typelets profile image
Typelets Team

Excellent catch! You're absolutely right to call this out. This article was written during our early implementation and we've just updated it to reflect our current architecture.

The old implementation you identified had exactly the vulnerability you described. We've since moved to a master password requirement for all users.

Current implementation:

  • Every user must set a master password that never leaves their device
  • Key derivation: PBKDF2(masterPassword, userSalt) with 250,000 iterations
  • Even knowing the userId and salt, you'd need the user's password to decrypt

The critical security change: There is now always a secret component (the master password) that only exists on the user's device. Without this password, the userId alone is completely useless for decryption - maintaining true zero-knowledge architecture.

Tradeoff: If users forget their master password, their notes are permanently inaccessible. But this is intentional - if we could recover passwords, we could decrypt data, whichwould break the zero-knowledge guarantee.

We've updated the article with the current implementation, code samples, and a specific section addressing your concern. Thanks for the security review! This is exactly the kind of scrutiny that makes zero-knowledge systems trustworthy!

You can see the updated implementation at app.typelets.com where you'll be prompted to create a master password on signup.