clavitor/clovis/clovis-vault/SPEC.md

14 KiB

Clavitor — 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

clavitor/
├── cmd/clavitor/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)

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)

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:

{
  "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:

{
  "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 Clavitor 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:
    {"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

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

VAULT_KEY=<hex-encoded 32 bytes>   # required, L1 master key
PORT=1984
DB_PATH=./clavitor.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: Clavitor
  • 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

Clavitor 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

Clavitor — 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

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

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