BACK TO BLOG
SecurityEncryptionFastAPIPython

End-to-End Encryption in a Password Manager: Implementation Deep Dive

How I implemented E2E encryption, TOTP, and zero-trust mode in Passman — the cryptographic choices and why they matter.

January 20, 202610 min read

The Core Principle: Zero Trust

In Passman, the server never sees your plaintext passwords. Ever. All encryption and decryption happens in the browser. The server stores only ciphertext — even if the database is compromised, your passwords are safe.

Key Derivation

The master password never leaves your device. We derive an encryption key from it using Argon2id:

import argon2

def derive_key(master_password: str, salt: bytes) -> bytes:
    return argon2.low_level.hash_secret_raw(
        secret=master_password.encode(),
        salt=salt,
        time_cost=3,
        memory_cost=65536,  # 64MB
        parallelism=4,
        hash_len=32,
        type=argon2.low_level.Type.ID,
    )

Argon2id is memory-hard, making brute-force attacks expensive even with GPUs.

Vault Encryption

Each vault entry is encrypted with AES-256-GCM using the derived key:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def encrypt_entry(key: bytes, plaintext: str) -> dict:
    aesgcm = AESGCM(key)
    nonce = os.urandom(12)  # 96-bit nonce
    ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)
    return {
        "nonce": base64.b64encode(nonce).decode(),
        "ciphertext": base64.b64encode(ciphertext).decode(),
    }

GCM mode gives us both confidentiality and integrity — if anyone tampers with the ciphertext, decryption fails.

TOTP Implementation

For MFA, we use TOTP (RFC 6238). The secret is stored encrypted in the vault:

import pyotp

def generate_totp_secret() -> str:
    return pyotp.random_base32()

def verify_totp(secret: str, token: str) -> bool:
    totp = pyotp.TOTP(secret)
    return totp.verify(token, valid_window=1)  # ±30s tolerance

The 40 Recovery Keys

If you lose your master password and MFA device, you're locked out. Recovery keys solve this. On account creation, we generate 40 random keys:

def generate_recovery_keys() -> list[str]:
    return [
        "-".join([secrets.token_hex(4).upper() for _ in range(4)])
        for _ in range(40)
    ]

Each key is hashed with bcrypt before storage. To recover, you provide one key — it's verified, then invalidated. You get 40 chances.

Zero-Trust Mode

In zero-trust mode, the session token is never stored in localStorage or cookies — only in memory. Closing the tab logs you out. This prevents XSS attacks from stealing your session.

What I'd Do Differently

Use WebCrypto API for all crypto operations in the browser instead of sending anything to the server. The current architecture is secure, but a pure client-side crypto approach would be even cleaner.

All PostsRana Dolui