Encrypted personal vault for stuff you probably shouldn't leave in a Google Doc.
Most of us store passwords and other sensitive info in Notion, Google Docs, Sheets, random wikis, whatever's handy. Bank details, AWS keys, Cloudflare logins, SMTP creds. We've always done it. It wasn't great before, but with AI in the picture it's worse.
Companies are hoovering up user data (sometimes with consent, sometimes not) and feeding it to employees or third parties for training, research, whatever. That doc you shared with the team? The sheet with prod credentials? More people (and systems) can get at that data than you think. You only need one person with bad intentions.
No server. The app runs in your browser and syncs to your Google Drive. Drive never sees the actual content, only encrypted blobs. Plaintext stays in memory while you're unlocked. We don't even put readable secrets in IndexedDB, just ciphertext.
It's open source. Don't trust my deployment. Fork it, read the code, host it yourself on Vercel/Cloudflare/S3/wherever. Use your own Google OAuth app if you want.
Build is just static files:
npm run build
# output in dist/Host dist/ anywhere that serves a SPA (Vercel, Cloudflare Pages, Netlify, S3 + CloudFront, etc.).
You'll need a Google OAuth client:
- Google Cloud Console → APIs & Services → Credentials → Create OAuth client ID → Web application
- Authorized JavaScript origins: your app URL(s), e.g.
http://localhost:5173for dev andhttps://vault.yourdomain.comfor prod - Copy the client ID into
VITE_GOOGLE_CLIENT_IDat build time (.env.localfor dev, env var in your host's build settings for prod) - Enable the Google Drive API for that project
The app only asks for drive.file scope. It can read/write files it created in Drive, not your whole Drive.
Data lands in a folder called ZeroTrustVault on the signed-in user's Drive.
npm install
npm run devYou need Google Drive. There's no local-only mode. Put this in .env.local:
VITE_GOOGLE_CLIENT_ID=your-oauth-client-id.apps.googleusercontent.comShort version: everything is encrypted in the browser before it hits Drive or IndexedDB. Drive and the local cache only see ciphertext. Your master password never gets stored anywhere.
master password (never stored, never leaves the device)
│ Argon2id (per-vault random salt)
▼
KEK (key-encryption key, in memory only)
│ AES-256-GCM wrap
▼
encryptedVaultKey (stored in vault/meta)
│ AES-256-GCM unwrap (needs KEK)
▼
Vault Key (DEK, in memory only; encrypts/decrypts everything else)
Your password doesn't encrypt the vault directly. Argon2id turns it into a KEK, which unwraps a separate random Vault Key (the thing that actually encrypts your data). That way changing the master password later is just re-wrapping one key, not re-encrypting the whole vault.
- KDF: Argon2id,
memorySize: 19456 KiB,iterations: 2,parallelism: 1, 16-byte salt per vault (src/crypto/kdf.ts) - Vault Key: 256 random bits, once per vault (
src/crypto/keys.ts) - Everything else: AES-256-GCM via Web Crypto, fresh 12-byte nonce per encrypt (
src/crypto/encrypt.ts). Tampered ciphertext just fails to decrypt.
vault/meta is the only thing stored without content encryption. You need to read it to unlock. It has:
formatVersion, KDF params (algorithm, salt, iterations, …)encryptedVaultKey: wrapped vault key (already encrypted, useless without your password)
Folders, secrets, versions, attachment metadata: all encrypted before storage (EncryptedStorageProvider) as:
{ "formatVersion": 1, "nonce": "<base64>", "ciphertext": "<base64>" }Big files get split into 512 KiB chunks. Each chunk gets its own AES-GCM encrypt + nonce, bundled into one envelope:
{ "formatVersion": 1, "kind": "chunked", "chunks": [ { "nonce": "...", "ciphertext": "..." }, ... ] }Download decrypts chunk by chunk and stitches them back together.
Create: random salt → derive KEK from password → generate Vault Key → wrap it → write {formatVersion, kdf, encryptedVaultKey} to vault/meta → wipe KEK from memory.
Unlock: read vault/meta → derive KEK again → try to unwrap encryptedVaultKey. Wrong password = GCM auth failure = "Incorrect master password." Right password = Vault Key stays in memory until you hit Lock (vaultKey.fill(0)).
VaultEngine
→ EncryptedStorageProvider (encrypt/decrypt with in-memory Vault Key)
→ MirrorStorageProvider (write: Drive first, then local cache)
→ GoogleDriveProvider (source of truth)
→ LocalStorageProvider (IndexedDB cache; reads come from here)
Reads prefer the local cache. Writes go to Drive first; the cache updates only after Drive confirms. Drive is the source of truth.
npm run lint
npm run typecheck
npm test
npm run build