All Work
SECURITY TOOL / PERSONAL SELF-INITIATED PRODUCT

passvault.

A personal, cross-device password vault: master password plus TOTP, zero-knowledge encryption, and a phone-approval login flow built to survive a hostile network, not just a friendly one.

SecurityCryptographyCross-Platform
Role
Author
Timeline
Web app 2026-06-24 to 08-27 (git-tracked), Android client through 09-02 (untracked)
Live URL
secrets.visioncrafters.ro ↗ (login screen only, publicly)
passvault login screen

01 / THE REQUEST

A vault the servercan't read.

A personal, cross-device password keeper: master password plus TOTP for auth, installable as a PWA, and specifically zero-knowledge, meaning the server storing the vault should never see plaintext passwords, only ciphertext it cannot decrypt.

02 / THE PROBLEM

Logging in froma phone you trust less.

Zero-knowledge encryption solves storage, not login. A cross-device flow where a phone approves a desktop session has to move the master password and a TOTP code across a channel without exposing them to a relay attacker, and without letting a compromised database hand out live session tokens.

03 / THE CHALLENGE

Encrypted, bound,and unreadable to the server.

The vault key is derived from the master password with PBKDF2, 210,000 iterations, SHA-256, a 16-byte random salt, into a non-extractable AES-256-GCM CryptoKey that cannot be pulled out of the browser even from DevTools (src/lib/crypto.ts:16-19,59-80). Every encrypt call generates a fresh random 12-byte IV, since IV reuse breaks GCM's guarantees entirely. A wrong password or tampered ciphertext throws on decrypt rather than returning garbage (crypto.ts:111-113). Supabase, the server, only ever stores ciphertext plus a salt and an IV, both non-secret, never plaintext and never the derived key.

Desktop generates a fresh RSA-OAEP-2048 keypair per login attempt and sends only the public key plus a random poll token to a Supabase Edge Function, which stores a hash of the poll token, never the raw value (supabase/functions/passvault-login/index.ts:77). Desktop and phone independently derive the same human-checkable 3-digit code from the public key (src/lib/phoneLoginCrypto.ts:47-52), so a relay attempting to substitute a different key produces a visibly different code the user can catch before approving.

The phone seals the master password, TOTP code and factor ID with a fresh AES-GCM content key, itself wrapped with the desktop's RSA public key, and binds Additional Authenticated Data of request ID, fingerprint, kind, attempt to the encryption (phoneLoginCrypto.ts:54-61). Decryption fails outright if any of those four fields don't match, so a sealed blob from one login attempt cannot be replayed against a different request or a retried attempt. Decrypted plaintext is explicitly zeroed after use in a finally block, not left to garbage collection (PhoneApprovalLogin.tsx:122).

The server enforces a constant 250ms response floor and returns an identically shaped response whether an account exists or not, so timing and response shape can't be used to enumerate accounts (passvault-login/index.ts:38-41,59-64). TOTP retries are capped at two attempts before a forced sign-out (PhoneApprovalLogin.tsx:113-116).

04 / THE SOLUTION

What gotbuilt.

Non-extractable AES-256-GCM vault key

PBKDF2 at 210,000 iterations derives a key the browser will not let JavaScript read back out.

Visually verified handshake

A shared 3-digit code, derived independently on both devices, catches a relayed public key before approval.

Request-bound encryption

AAD ties every sealed login payload to one request, attempt and device fingerprint, so replay fails by design.

Enumeration-resistant server

A constant response floor and uniform response shape hide whether an account exists at all.

05 / THE RESULT

Live,in daily use.

BUILT WITH

ViteReact 19SupabaseWeb Crypto APIZustandKotlin (Android)

Need security done properly?

Next project

Layla Voice PWA