What is the Local Vault?
The Local Vault is a client-side encryption system that stores your private stories in your browser’s IndexedDB with AES-256-GCM encryption. Your stories are encrypted locally using a 6-digit PIN—no encryption keys ever leave your device. This provides an additional privacy layer beyond cloud storage.Why Use the Local Vault?
Zero-Knowledge Privacy
Even eStory servers can’t read your vault stories. Encryption happens in your browser.
Offline Access
Access encrypted stories without internet. Perfect for airplane mode or poor connectivity.
PIN-Protected
Only you can unlock the vault with your 6-digit PIN. Brute-force takes ~277 hours (100K PBKDF2 iterations).
Multi-Device Sync (Future)
Export encrypted vault backup to sync across devices (Phase 2 feature).
How Vault Encryption Works
First-Time Setup
When you set a PIN:
- A random DEK (Data Encryption Key) is generated using
crypto.subtle.generateKey() - Your PIN is stretched with PBKDF2 (100,000 iterations + random salt) to create a KEK (Key Encryption Key)
- The DEK is wrapped (encrypted) with the KEK using AES-KW (key wrapping)
- The wrapped DEK and salt are stored in IndexedDB (table:
vaultKeys)
Your PIN never touches the disk. Only the salt and wrapped DEK are stored.
Unlocking the Vault
When you enter your PIN:
- Your PIN + stored salt → PBKDF2 → KEK (re-derived)
- KEK unwraps the wrapped DEK from IndexedDB
- DEK is held in memory only (JavaScript variable)
- Vault state:
unlocked
Encrypting a Story
When you save a story while vault is unlocked:
- Title and content are encrypted with AES-256-GCM using the in-memory DEK
- A fresh random 12-byte IV (initialization vector) is generated for each field
- Ciphertext (base64) + IV (base64) stored in IndexedDB (table:
stories) - A SHA-256 checksum of plaintext is stored for integrity verification
Decrypting a Story
When you view a vault story:
- Ciphertext + IV retrieved from IndexedDB
- Decrypted using the in-memory DEK
- Checksum verified (detects tampering)
- Plaintext displayed in UI
Encryption Specifications
| Component | Algorithm | Strength |
|---|---|---|
| Data Encryption | AES-256-GCM | 256-bit key (unbreakable with current tech) |
| Key Derivation | PBKDF2-SHA256 | 100,000 iterations (10ms/guess) |
| Key Wrapping | AES-KW (RFC 3394) | 256-bit KEK |
| Integrity | GCM authentication tag | Detects tampering |
| Checksum | SHA-256 | Verifies decryption correctness |
Why This Is Secure
256-bit AES is military-grade
256-bit AES is military-grade
Used by governments for top-secret data. A brute-force attack would take billions of years even with supercomputers.
PBKDF2 slows down PIN cracking
PBKDF2 slows down PIN cracking
Each PIN guess takes 10ms (100K iterations). Testing all 1,000,000 possible 6-digit PINs takes 277 hours.Compare to no PBKDF2: 1M guesses in ~1 second.
Fresh IV per encryption prevents pattern leaks
Fresh IV per encryption prevents pattern leaks
Even if you encrypt the same story twice, ciphertexts are completely different (no pattern analysis).
GCM mode provides authenticated encryption
GCM mode provides authenticated encryption
Detects if ciphertext is modified. Decryption fails if data is tampered with.
Vault Setup Guide
Create a 6-Digit PIN
Enter a memorable 6-digit PIN (e.g.,
123456 for testing, but use a strong one in production).Vault Created
You’ll see a success message: “Vault setup complete!”Vault state:
setup + unlocked (DEK in memory).Using the Vault
Unlocking
- Click Unlock Vault button
- Enter your 6-digit PIN
- If correct: Vault unlocks, encrypted stories become readable
- If wrong: Error message:
"Incorrect PIN"
- Sign out
- Close the browser tab (DEK is in-memory only)
- Click Lock Vault button
Viewing Encrypted Stories
Vault Locked:- Stories show
[Encrypted]in title/content preview - Click to unlock prompts you for PIN
- Stories decrypt automatically in real-time
- Fetched via
useLocalStories()hook (reactive Dexie query)
Changing Your PIN
Go to Settings → Change PIN
Enter:
- Current PIN (verified against stored hash)
- New PIN (6 digits)
- Confirm New PIN
/lib/vault/keyManager.ts:145-181
Vault Database Schema
vaultKeys Table
stories Table
localId(primary)userId(filter by user)sync_status(pending uploads)cloud_id(link to Supabase)
Dual-Write: Cloud + Vault
When you save a story, it’s written to both Supabase and Vault:- Cloud copy: Accessible from any device, searchable, shareable
- Vault copy: Offline-first, encrypted, survives cloud outages
/app/record/RecordPageClient.tsx:394-427
Vault Sync Status
Each vault story has async_status field:
| Status | Meaning |
|---|---|
local | Saved only to vault (not uploaded to cloud yet) |
pending | Queued for cloud upload |
synced | Successfully saved to both vault and Supabase |
error | Cloud upload failed (retryable) |
pending stories when online.
Vault UI Components
<VaultGuard> Wrapper
Wrap any component to handle vault states:
- Not setup: Shows setup instructions
- Locked: Shows unlock prompt
- Unlocked: Renders children
useLocalStories() Hook
Reactive hook for vault CRUD:
stories: Array of decryptedLocalStoryRecordisLoading: Boolean (IndexedDB query in progress)- Auto-updates when vault unlocks/locks (reactive via Dexie
useLiveQuery)
Security Best Practices
Choose a Strong PIN
Choose a Strong PIN
Weak:
123456, 000000, birthdateStrong: Random 6 digits from password manager (e.g., 847293)With 100K PBKDF2 iterations, a random 6-digit PIN takes ~277 hours to brute-force.Lock Vault When Not in Use
Lock Vault When Not in Use
Click Lock Vault in settings before stepping away from your device.This clears the DEK from memory—even if someone accesses your browser, they can’t read vault stories without your PIN.
Backup Your Vault (Phase 2)
Backup Your Vault (Phase 2)
Future feature: Export encrypted vault backup to another device.Current limitation: Vault is tied to one browser. Clearing browser data = vault lost.
Never Share Your PIN
Never Share Your PIN
Limitations & Tradeoffs
Vault is Additive, Not RequiredYou can use eStory without ever setting up a vault. Cloud storage (Supabase) is the primary system. Vault is an optional privacy enhancement.
Troubleshooting
”Vault is locked — unlock with PIN first”
Cause: DEK is not in memory (vault locked or you just reopened the browser). Solution: Click Unlock Vault and enter your PIN.”Incorrect PIN” on unlock
Cause: Wrong PIN entered. Solution: Try again. After 3 failed attempts, wait 30 seconds (future rate limiting).Vault stories disappeared after clearing browser data
Cause: IndexedDB is browser storage—clearing “site data” deletes it. Solution: None (data is lost). Future feature: cloud backup of encrypted vault. Prevention: Don’t clear browser data, or export vault backup first.”Decryption failed” on viewing story
Cause: Ciphertext corrupted or checksum mismatch. Solution: This is rare (indicates storage corruption). Try re-saving the story from cloud copy.Privacy Comparison
| Feature | Cloud Storage (Supabase) | Local Vault (IndexedDB) |
|---|---|---|
| Encryption | Database-level (at rest) | AES-256-GCM (client-side) |
| Access Control | RLS policies (DB rules) | PIN-protected (your device) |
| Server Visibility | Admins can read plaintext | Encrypted blobs (unreadable) |
| Offline Access | No (requires network) | Yes (IndexedDB is local) |
| Multi-Device | Yes (sync via cloud) | No (single browser) |
| Backup | Automatic (Supabase backups) | Manual export (coming soon) |
| Recovery | Email/wallet reset | None (PIN forgotten = lost) |
Code References
| File | Purpose |
|---|---|
/lib/vault/crypto.ts | AES-GCM encryption, PBKDF2 derivation |
/lib/vault/keyManager.ts | Setup, unlock, lock, PIN change |
/lib/vault/db.ts | Dexie schema (IndexedDB) |
/lib/vault/index.ts | Public API exports |
/components/vault/VaultGuard.tsx | UI wrapper for vault states |
/app/hooks/useLocalStories.ts | Reactive vault CRUD hook |
/app/record/RecordPageClient.tsx:394 | Dual-write (cloud + vault) |
Next Steps
Voice Journaling
Record stories that auto-encrypt to vault when saved privately
Blockchain Storage
Understand how cloud stories are pinned to IPFS for permanence
Profile Settings
Export and import vault backups across devices (Phase 2)