vault1984/docs/SPEC.md

443 lines
14 KiB
Markdown

# 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=<hex-encoded 32 bytes> # 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."