# 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."
