# Vault1984 — SPEC v0.1 *A personal vault built for humans who have AI assistants.* --- ## The Problem Existing password managers are built for human→human sharing. The AI assistant use case is different: - Your AI needs ~30 of your 500 credentials to do its job - Some credentials should never reach an AI regardless of trust - Card numbers, CVV, passport data — client-side decrypt only - Everything else — AI can read, use, act on Bitwarden MCP server exists but is all-or-nothing. Nothing has two-tier encryption with field-level AI visibility, WebAuthn unlock, and LLM-powered field mapping. --- ## Design Principles 1. **One binary** — Go, cross-compiles mac/linux/windows, single port 2. **One file** — SQLite DB, platform-independent, portable 3. **Entry model** — same tree as inou/dealspace: everything is an entry 4. **Two-tier encryption** — L1 (server key, AI-readable) + L2 (client-side WebAuthn only) 5. **LLM import** — any format, no format-specific parsers 6. **LLM field mapping** — extension fills forms intelligently 7. **No external dependencies** — no cloud, no subscriptions --- ## Architecture ``` vault1984/ ├── cmd/vault1984/main.go # single entrypoint ├── api/ # REST + MCP handlers ├── lib/ # crypto, dbcore, types ├── web/ # embedded SPA (go:embed, vanilla JS) └── extension/ # Chrome extension (no build step) ``` Single binary, one port (default 8765): - `GET /` → embedded web UI - `/api/*` → REST API - `/mcp` → MCP endpoint (AI, L1 only) - `/ext/*` → extension API (full access) --- ## Data Model ### Entry table (single table for everything) ```go type Entry struct { EntryID string // uuid ParentID string // folder entry_id, or "" for root Type string // credential|note|identity|card|ssh_key|totp|folder|any Title string // plaintext title TitleIdx string // HMAC-SHA256 blind index for search Data []byte // zstd + AES-256-GCM (L1 key server-side) // OR: zstd + AES-256-GCM (L2 key client-side only) DataLevel int // 1=L1, 2=L2 CreatedAt int64 UpdatedAt int64 Version int // optimistic locking } ``` ### VaultData (packed into Entry.Data) ```go type VaultData struct { Title string `json:"title"` Type string `json:"type"` Fields []VaultField `json:"fields"` URLs []string `json:"urls,omitempty"` Tags []string `json:"tags,omitempty"` Expires string `json:"expires,omitempty"` // YYYY-MM-DD Notes string `json:"notes,omitempty"` Files []VaultFile `json:"files,omitempty"` } type VaultField struct { Label string `json:"label"` // "Username", "Password", "CVV" — anything Value string `json:"value"` // plaintext after decrypt Kind string `json:"kind"` // text|password|totp|url|file Section string `json:"section,omitempty"` // visual grouping L2 bool `json:"l2,omitempty"` // true = client-side decrypt only } type VaultFile struct { Name string `json:"name"` MimeType string `json:"mime_type"` Size int64 `json:"size"` Data []byte `json:"data"` // encrypted blob stored in SQLite } ``` The `type` field is just a UI hint — it never constrains the fields. A "shoe size" entry is valid. A "custom API token" entry is valid. ### Example entries **Credit card:** ```json { "type": "card", "title": "Amex Platinum", "fields": [ {"label":"Cardholder","value":"Johan Jongsma","kind":"text"}, {"label":"Number","value":"3782 8224 6310 005","kind":"password","l2":true}, {"label":"CVV","value":"1234","kind":"password","l2":true}, {"label":"Expiry","value":"09/28","kind":"text"}, {"label":"Bank","value":"American Express","kind":"text"} ] } ``` AI sees: Cardholder, Expiry, Bank. Never Number or CVV. **Identity:** ```json { "type": "identity", "title": "Johan Jongsma", "fields": [ {"label":"First Name","value":"Johan","section":"Personal"}, {"label":"Last Name","value":"Jongsma","section":"Personal"}, {"label":"Email","value":"johan@jongsma.me","section":"Personal"}, {"label":"Phone","value":"+17272252475","section":"Personal"}, {"label":"Address","value":"851 Brightwaters Blvd","section":"Address"}, {"label":"City","value":"St. Petersburg","section":"Address"}, {"label":"State","value":"FL","section":"Address"}, {"label":"ZIP","value":"33704","section":"Address"}, {"label":"Passport","value":"NL12345678","kind":"password","l2":true,"section":"Documents"} ] } ``` --- ## Two-Tier Encryption ### L1 — Server Key (AI-readable) - `VAULT_KEY` env var (hex-encoded 32 bytes) - Per-entry key: HKDF-SHA256(VAULT_KEY, entry_id) - Encryption: zstd compress → AES-256-GCM encrypt → store - AI (MCP) can read all L1 fields - L2 fields within an L1 entry: individual field values are further encrypted with L2 key before being packed into VaultData — server stores ciphertext, returns `{"label":"CVV","value":null,"l2":true}` to AI ### L2 — Client Key (WebAuthn PRF, never leaves device) - Derived entirely in browser via WebAuthn PRF extension - `navigator.credentials.get({extensions:{prf:{eval:{first:salt}}}})` → 32-byte secret - Secret → HKDF → L2 key → decrypt L2 field values in browser (Web Crypto API) - Server NEVER sees L2 key or L2 plaintext - L2 key lives in `sessionStorage` only — gone on tab close - Auto-lock after 15 minutes idle (configurable) ### WebAuthn authenticators supported - Touch ID (Mac) — native browser prompt - Face ID (iPhone, via web UI on iOS Safari) - Windows Hello - Google Titan Key (USB-A/NFC, FIDO2) - YubiKey (FIDO2) Register multiple authenticators for redundancy. One unlock = all L2 accessible. ### Recovery - Printed 12-word BIP39 mnemonic generated at vault setup - Mnemonic derives L2 key as last resort (entropy → HKDF) - Store physically (safe, safety deposit box) - NO email/SMS/server fallback — would break the security model --- ## Import (LLM-powered) `POST /api/import` — multipart, accepts any file format Flow: 1. User uploads file (Bitwarden JSON, 1Password CSV, LastPass, Chrome, plain text) 2. Server sends content to Fireworks LLM (zero retention): > "Parse this password export into Vault1984 entries. Return JSON array of > VaultData. Guess types. Mark l2:true on fields that appear sensitive: > card numbers, CVV, SSN, passport numbers, private keys, TOTP seeds." 3. Server returns preview — user reviews before committing 4. **L2 fields highlighted in amber** — "These fields will NOT be readable by your AI" 5. User can toggle L2 per field in preview 6. Confirm → write to DB Best practice enforced by UI: "Review L2 markings before importing." Sensitive fields default to L2 — user explicitly unlocks them for AI if desired. --- ## Browser Extension Manifest V3, Chrome first. ### Files - `manifest.json` - `background.js` — service worker, API key, vault calls - `content.js` — form detection, field filling - `popup.html/js` — credential picker ### Extension API key Stored in `chrome.storage.local`. Full access (L1 + L2 via client-side decrypt). Extension has its own token type: `ext` — distinguishable in audit log. ### LLM Field Mapping 1. Content script detects forms (any login/fill page) 2. Serializes visible inputs: label, name, placeholder, type, selector 3. User opens popup → picks credential 4. Background calls `POST /ext/map`: ```json {"entry_id":"...", "fields":[{"selector":"#email","label":"Email","type":"email"},...]} ``` 5. Server calls LLM: maps vault fields to form selectors 6. Content script fills mapped fields 7. TOTP field detected → live code generated → filled automatically ### L2 fill flow - Extension popup shows 🔒 on L2 fields - Click → WebAuthn prompt in browser (Touch ID etc.) - L2 key derived → L2 fields decrypted in browser memory → filled - Key discarded after fill --- ## TOTP Seed stored as L2 field by default (user can change to L1 per entry). **If L1 (AI-accessible):** - `GET /api/ext/totp/:id` → `{"code":"123456","expires_in":23}` - MCP tool `get_totp("GitHub")` → returns live code - AI can complete 2FA flows autonomously — killer feature **If L2 (client-only):** - Seed only decryptable client-side - Extension generates code locally after WebAuthn unlock - AI cannot access Default: **L2** for TOTP seeds. User explicitly marks L1 to enable AI 2FA automation. This is a conscious, visible decision — not a silent default. --- ## MCP Tools (L1 only) ``` get_credential(query) → VaultData, L2 fields omitted list_credentials(filter?) → [{entry_id, title, type, urls}] get_totp(query) → {code, expires_in} (L1 TOTP only) search_vault(query) → [{entry_id, title, type, matched_field}] check_expiring(days?) → [{title, type, expires, days_remaining}] create_entry(data) → entry_id (AI can save new credentials) ``` MCP token: read-only by default. Write token optional (for AI to save new credentials). --- ## Web UI Vanilla JS, embedded in binary, no framework, no build step. ### Views - `/` → entry list, folder tree, search - `/entry/:id` → detail view - `/entry/new` → create (dynamic field builder, type picker) - `/import` → LLM import with preview - `/settings` → WebAuthn setup, tokens, export, audit log ### L2 UX - L2 fields render as: `🔒 Locked — Touch to unlock` - Click → WebAuthn prompt → decrypt in browser → show value + copy button - "Unlock all" button → single WebAuthn prompt → all L2 visible for session - Lock icon in nav shows session state (locked/unlocked) --- ## Audit Log ```go type AuditEvent struct { EventID string EntryID string Title string // snapshot Action string // read|fill|ai_read|create|update|delete|import|export Actor string // web|extension|mcp IPAddr string Timestamp int64 } ``` `GET /api/audit` — paginated, filterable by actor/action/entry. AI access clearly marked as `actor:"mcp"`. --- ## Password Generator `GET /api/generate?length=20&symbols=true` `GET /api/generate?words=4` → "correct-horse-battery-staple" Crypto/rand throughout. Built into field editor. --- ## Config ```bash VAULT_KEY= # required, L1 master key PORT=1984 DB_PATH=./vault1984.db FIREWORKS_API_KEY=... # for LLM import + field mapping LLM_MODEL=accounts/fireworks/models/llama-v3p3-70b-instruct SESSION_TTL=86400 # seconds L2_LOCK_IDLE=900 # L2 auto-lock after 15min idle ``` --- ## Build Plan ### Day 1 — Foundation - [ ] Go module, entry model, L1 crypto, dbcore - [ ] CRUD API (create/read/update/delete entries) - [ ] Web UI: list, create, view, edit - [ ] Password generator - [ ] Session auth (token-based) ### Day 2 — L2 + Extension - [ ] L2 field encryption (client-side Web Crypto in browser) - [ ] WebAuthn PRF registration + unlock flow - [ ] Chrome extension: popup, content script, LLM fill - [ ] `/ext/map` LLM field mapping endpoint ### Day 3 — AI + Import - [ ] MCP endpoint + 6 tools - [ ] OpenClaw skill: Vault1984 - [ ] LLM import (`/api/import`) with preview UI - [ ] TOTP generation ### Day 4 — Complete - [ ] Audit log - [ ] File attachments (BLOB in SQLite) - [ ] Expiry alerts (heartbeat + MCP) - [ ] Export (Bitwarden-compatible JSON) - [ ] Systemd service + deploy script --- ## Out of Scope v0.1 - Mobile autofill (v2, OSS community) - Firefox/Safari extension - Multi-user/team sharing - Browser extension for non-Chromium --- ## Competitive Landscape | | Vault1984 | Bitwarden MCP | mcp-secrets-vault | 1Password | |--|--|--|--|--| | Field-level AI visibility | ✅ | ❌ | ❌ | ❌ | | Two-tier encryption | ✅ | ❌ | ❌ | ❌ | | WebAuthn L2 unlock | ✅ | ❌ | ❌ | ✅ (auth only) | | LLM field mapping | ✅ | ❌ | ❌ | ❌ | | LLM import (any format) | ✅ | ❌ | ❌ | ❌ | | One binary | ✅ | ❌ | ✅ | ❌ | | Self-hosted | ✅ | ✅ | ✅ | ❌ | | Open source | ✅ | ✅ | ✅ | ❌ | --- *Vault1984 — the vault that knows who it's talking to.* --- ## Scoped MCP Tokens (Multi-Agent Support) *Added 2026-02-28 — targeting multi-agent swarm use case* ### The problem One MCP token = all L1 entries. In a 10-agent swarm, Agent 3 doesn't need your bank credentials. A compromised agent leaks everything. ### Design ```go type Token struct { Token string `json:"token"` Actor string `json:"actor"` // mcp | web | ext Label string `json:"label"` // "Agent: Social Media" Tags []string `json:"tags"` // if set: only return entries with matching tags EntryIDs []string `json:"entry_ids"` // if set: only return these specific entries ReadOnly bool `json:"read_only"` ExpiresAt int64 `json:"expires_at"` // 0 = never } ``` ### Usage ```bash # Create a scoped token for a social media agent POST /api/auth/token { "label": "Agent: Social Media", "actor": "mcp", "tags": ["social", "twitter", "linkedin"], "read_only": true } ``` Agent uses this token → can only see entries tagged `social`, `twitter`, or `linkedin`. Nothing else exists from its perspective. ### Audit log Each access logged with token label: ``` [mcp] Agent: Social Media → read "Twitter API Key" [mcp] Agent: DevOps → read "GitHub Deploy Key" ``` One compromise = one agent's scope. Not your whole vault. ### This is the killer feature for swarms > "Running 10 agents? Give each one a token scoped to exactly what it needs. > One token per agent. One breach = one agent's credentials. > Everything else stays locked."