commit 0ff6db74cb43fa447f27dced513b4d3897bb557a Author: James (AI) Date: Sat Feb 28 15:42:48 2026 -0500 Initial ClawVault: entry model, L1 crypto, CRUD API, web UI, extension scaffold Features: - Two-tier encryption (L1 server-side, L2 client-side placeholder) - SQLite with WAL mode - HKDF per-entry key derivation - zstd + AES-256-GCM encryption - HMAC-SHA256 blind indexes for search - Session-based auth - Full CRUD API - Password generator (random + passphrase) - TOTP generation (L1 only, L2 returns flag) - LLM import endpoint (Fireworks) - LLM field mapping endpoint - MCP JSON-RPC endpoint with 5 tools - Vanilla JS web UI (Tailwind, dark theme) - Chrome extension scaffold (MV3) - Audit logging Day 2: WebAuthn PRF, extension autofill, full L2 flow diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f653d --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binaries (only at root) +/clawvault +*.exe + +# Database +*.db +*.db-journal +*.db-wal +*.db-shm + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test files +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..70e6c9f --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# ClawVault + +A personal vault with two-tier encryption for AI assistants. + +## Features + +- **Two-tier encryption**: L1 (server-side, AI-readable) + L2 (client-side only) +- **Single binary**: Go, cross-compiles, one port (default 8765) +- **Single file**: SQLite database, portable +- **LLM-powered import**: Parse any password manager export format +- **LLM field mapping**: Smart autofill via Chrome extension +- **MCP endpoint**: AI assistant integration +- **TOTP generation**: Live TOTP codes for L1 entries + +## Quick Start + +```bash +# Generate vault key +export VAULT_KEY=$(openssl rand -hex 32) +export PORT=8765 +export DB_PATH=./clawvault.db + +# Run +./clawvault +``` + +## Building + +```bash +CGO_ENABLED=1 go build ./cmd/clawvault +``` + +## API Endpoints + +- `GET /health` - Health check +- `POST /api/auth/setup` - Initialize session +- `GET/POST /api/entries` - CRUD entries +- `GET /api/search?q=` - Search entries +- `GET /api/generate` - Password generator +- `POST /api/import` - LLM import +- `GET /api/ext/totp/:id` - TOTP codes +- `GET /api/ext/match?url=` - URL matching +- `POST /api/ext/map` - LLM field mapping +- `POST /mcp` - MCP JSON-RPC endpoint +- `GET /api/audit` - Audit log + +## Chrome Extension + +Load `/extension` as unpacked extension in Chrome. + +## License + +Private - Johan Jongsma diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..13c3eb0 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,388 @@ +# ClawVault — 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 + +``` +clawvault/ +├── cmd/clawvault/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 ClawVault 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=8765 +DB_PATH=./clawvault.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: ClawVault +- [ ] 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 + +| | ClawVault | 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 | ✅ | ✅ | ✅ | ❌ | + +--- + +*ClawVault — the vault that knows who it's talking to.* diff --git a/api/handlers.go b/api/handlers.go new file mode 100644 index 0000000..70ae1b6 --- /dev/null +++ b/api/handlers.go @@ -0,0 +1,1046 @@ +package api + +import ( + "bytes" + "crypto/rand" + "encoding/base32" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/johanj/clawvault/lib" + "github.com/pquerna/otp/totp" +) + +// Handlers holds dependencies for HTTP handlers. +type Handlers struct { + DB *lib.DB + Cfg *lib.Config +} + +// NewHandlers creates a new Handlers instance. +func NewHandlers(db *lib.DB, cfg *lib.Config) *Handlers { + return &Handlers{DB: db, Cfg: cfg} +} + +// --------------------------------------------------------------------------- +// Health & Setup +// --------------------------------------------------------------------------- + +// Health returns server status. +func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { + count, _ := lib.EntryCount(h.DB) + JSONResponse(w, http.StatusOK, map[string]any{ + "status": "ok", + "entries": count, + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +// Setup creates the initial session (first-time setup). +func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { + // Create a web session + session, err := lib.SessionCreate(h.DB, h.Cfg.SessionTTL, lib.ActorWeb) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session") + return + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + Action: "setup", + Actor: lib.ActorWeb, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusOK, map[string]string{ + "token": session.Token, + }) +} + +// --------------------------------------------------------------------------- +// Entry CRUD +// --------------------------------------------------------------------------- + +// ListEntries returns all entries (tree structure). +func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + parentID := r.URL.Query().Get("parent_id") + var parent *string + if parentID != "" { + parent = &parentID + } + + entries, err := lib.EntryList(h.DB, h.Cfg, parent) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") + return + } + if entries == nil { + entries = []lib.Entry{} + } + + // For MCP, strip L2 field values + if actor == lib.ActorMCP { + for i := range entries { + if entries[i].VaultData != nil { + stripL2Fields(entries[i].VaultData) + } + } + } + + JSONResponse(w, http.StatusOK, entries) +} + +// GetEntry returns a single entry. +func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + entryID := chi.URLParam(r, "id") + + entry, err := lib.EntryGet(h.DB, h.Cfg, entryID) + if err == lib.ErrNotFound { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") + return + } + + // Check if soft-deleted + if entry.DeletedAt != nil { + ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted") + return + } + + // For MCP, strip L2 field values + if actor == lib.ActorMCP && entry.VaultData != nil { + stripL2Fields(entry.VaultData) + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + EntryID: entry.EntryID, + Title: entry.Title, + Action: lib.ActionRead, + Actor: actor, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusOK, entry) +} + +// CreateEntry creates a new entry. +func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + + var req struct { + Type string `json:"type"` + Title string `json:"title"` + ParentID string `json:"parent_id"` + Data *lib.VaultData `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + if req.Title == "" { + ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required") + return + } + if req.Type == "" { + req.Type = lib.TypeCredential + } + + entry := &lib.Entry{ + Type: req.Type, + Title: req.Title, + ParentID: req.ParentID, + DataLevel: lib.DataLevelL1, + VaultData: req.Data, + } + + if err := lib.EntryCreate(h.DB, h.Cfg, entry); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry") + return + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + EntryID: entry.EntryID, + Title: entry.Title, + Action: lib.ActionCreate, + Actor: actor, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusCreated, entry) +} + +// UpdateEntry updates an existing entry. +func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + entryID := chi.URLParam(r, "id") + + var req struct { + Type string `json:"type"` + Title string `json:"title"` + ParentID string `json:"parent_id"` + Version int `json:"version"` + Data *lib.VaultData `json:"data"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + // Get existing entry + existing, err := lib.EntryGet(h.DB, h.Cfg, entryID) + if err == lib.ErrNotFound { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") + return + } + + // Update fields + if req.Title != "" { + existing.Title = req.Title + } + if req.Type != "" { + existing.Type = req.Type + } + existing.ParentID = req.ParentID + existing.Version = req.Version + if req.Data != nil { + existing.VaultData = req.Data + } + + if err := lib.EntryUpdate(h.DB, h.Cfg, existing); err != nil { + if err == lib.ErrVersionConflict { + ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error()) + return + } + ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry") + return + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + EntryID: existing.EntryID, + Title: existing.Title, + Action: lib.ActionUpdate, + Actor: actor, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusOK, existing) +} + +// DeleteEntry soft-deletes an entry. +func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + entryID := chi.URLParam(r, "id") + + // Get entry for audit log + entry, _ := lib.EntryGet(h.DB, h.Cfg, entryID) + + if err := lib.EntryDelete(h.DB, entryID); err != nil { + if err == lib.ErrNotFound { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry") + return + } + + title := "" + if entry != nil { + title = entry.Title + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + EntryID: entryID, + Title: title, + Action: lib.ActionDelete, + Actor: actor, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +// SearchEntries searches entries by title. +func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + query := r.URL.Query().Get("q") + if query == "" { + ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required") + return + } + + // Use fuzzy search for practicality + entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed") + return + } + if entries == nil { + entries = []lib.Entry{} + } + + // For MCP, strip L2 field values + if actor == lib.ActorMCP { + for i := range entries { + if entries[i].VaultData != nil { + stripL2Fields(entries[i].VaultData) + } + } + } + + JSONResponse(w, http.StatusOK, entries) +} + +// --------------------------------------------------------------------------- +// Password Generator +// --------------------------------------------------------------------------- + +// GeneratePassword generates a random password. +func (h *Handlers) GeneratePassword(w http.ResponseWriter, r *http.Request) { + lengthStr := r.URL.Query().Get("length") + length := 20 + if lengthStr != "" { + if l, err := strconv.Atoi(lengthStr); err == nil && l > 0 && l <= 128 { + length = l + } + } + + symbols := r.URL.Query().Get("symbols") != "false" + words := r.URL.Query().Get("words") == "true" + + var password string + if words { + password = generatePassphrase(4) + } else { + password = generatePassword(length, symbols) + } + + JSONResponse(w, http.StatusOK, map[string]string{ + "password": password, + }) +} + +func generatePassword(length int, symbols bool) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + const digits = "0123456789" + const syms = "!@#$%^&*()_+-=[]{}|;:,.<>?" + + charset := letters + digits + if symbols { + charset += syms + } + + b := make([]byte, length) + rand.Read(b) + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b) +} + +func generatePassphrase(words int) string { + wordList := []string{ + "correct", "horse", "battery", "staple", "cloud", "mountain", + "river", "forest", "castle", "dragon", "phoenix", "crystal", + "shadow", "thunder", "whisper", "harvest", "journey", "compass", + "anchor", "beacon", "bridge", "canyon", "desert", "empire", + } + b := make([]byte, words) + rand.Read(b) + parts := make([]string, words) + for i := range parts { + parts[i] = wordList[int(b[i])%len(wordList)] + } + return strings.Join(parts, "-") +} + +// --------------------------------------------------------------------------- +// Extension API +// --------------------------------------------------------------------------- + +// GetTOTP generates a live TOTP code for an entry. +func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + entryID := chi.URLParam(r, "id") + + entry, err := lib.EntryGet(h.DB, h.Cfg, entryID) + if err == lib.ErrNotFound { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") + return + } + + if entry.VaultData == nil { + ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field") + return + } + + // Find TOTP field + var totpSeed string + var isL2 bool + for _, field := range entry.VaultData.Fields { + if field.Kind == "totp" { + if field.L2 { + isL2 = true + } else { + totpSeed = field.Value + } + break + } + } + + if isL2 { + JSONResponse(w, http.StatusOK, map[string]any{ + "l2": true, + }) + return + } + + if totpSeed == "" { + ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field") + return + } + + // Normalize seed (remove spaces, uppercase) + totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", "")) + + // Generate TOTP code + code, err := totp.GenerateCode(totpSeed, time.Now()) + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed") + return + } + + // Calculate time until expiry (30 second window) + now := time.Now().Unix() + expiresIn := 30 - (now % 30) + + lib.AuditLog(h.DB, &lib.AuditEvent{ + EntryID: entry.EntryID, + Title: entry.Title, + Action: "totp", + Actor: actor, + IPAddr: realIP(r), + }) + + JSONResponse(w, http.StatusOK, map[string]any{ + "code": code, + "expires_in": expiresIn, + "l2": false, + }) +} + +// MatchURL finds credentials matching a URL (for extension popup). +func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) { + urlStr := r.URL.Query().Get("url") + if urlStr == "" { + ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required") + return + } + + // Extract domain from URL + domain := extractDomain(urlStr) + + // Get all entries and filter by URL + entries, err := lib.EntryList(h.DB, h.Cfg, nil) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") + return + } + + var matches []lib.Entry + for _, entry := range entries { + if entry.VaultData == nil { + continue + } + for _, u := range entry.VaultData.URLs { + if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) { + matches = append(matches, entry) + break + } + } + } + + if matches == nil { + matches = []lib.Entry{} + } + + JSONResponse(w, http.StatusOK, matches) +} + +// MapFields uses LLM to map vault fields to form fields. +func (h *Handlers) MapFields(w http.ResponseWriter, r *http.Request) { + if h.Cfg.FireworksAPIKey == "" { + ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured") + return + } + + var req struct { + EntryID string `json:"entry_id"` + PageFields []struct { + Selector string `json:"selector"` + Label string `json:"label"` + Type string `json:"type"` + Placeholder string `json:"placeholder"` + } `json:"page_fields"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + entry, err := lib.EntryGet(h.DB, h.Cfg, req.EntryID) + if err != nil { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + + if entry.VaultData == nil { + ErrorResponse(w, http.StatusBadRequest, "no_data", "Entry has no data") + return + } + + // Build field lists for LLM + var vaultFields []string + for _, f := range entry.VaultData.Fields { + if !f.L2 { // Only include L1 fields + vaultFields = append(vaultFields, f.Label) + } + } + + var formFields []string + for _, f := range req.PageFields { + desc := f.Selector + if f.Label != "" { + desc = f.Label + " (" + f.Selector + ")" + } + formFields = append(formFields, desc) + } + + // Call LLM + prompt := fmt.Sprintf(`Map these vault fields to these HTML form fields. Return JSON object mapping vault_field_label to css_selector. + +Vault fields: %s +Form fields: %s + +Return ONLY valid JSON, no explanation. Example: {"Username":"#email","Password":"#password"}`, + strings.Join(vaultFields, ", "), + strings.Join(formFields, ", ")) + + llmResp, err := callLLM(h.Cfg, "You are a field mapping assistant. Map credential fields to form fields.", prompt) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed") + return + } + + // Parse LLM response + var mapping map[string]string + if err := json.Unmarshal([]byte(llmResp), &mapping); err != nil { + ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") + return + } + + JSONResponse(w, http.StatusOK, mapping) +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +// ImportEntries handles LLM-powered import from any format. +func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) { + if h.Cfg.FireworksAPIKey == "" { + ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured") + return + } + + actor := ActorFromContext(r.Context()) + + // Parse multipart form (max 10MB) + if err := r.ParseMultipartForm(10 << 20); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form") + return + } + + file, _, err := r.FormFile("file") + if err != nil { + ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required") + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "read_failed", "Failed to read file") + return + } + + // Call LLM to parse + prompt := fmt.Sprintf(`Parse this password manager export into an array of VaultData objects. + +For each entry, create a JSON object with: +- title: string +- type: one of "credential", "card", "identity", "note", "ssh_key", "totp" +- fields: array of {label, value, kind, l2} + - kind: "text", "password", "totp", "url" + - l2: true for sensitive fields (card numbers, CVV/CVC, SSN, passport numbers, private keys, TOTP seeds) +- urls: array of URLs if applicable +- notes: any notes + +Mark l2:true on these sensitive field types: +- Card numbers, CVV/CVC codes +- SSN, passport numbers +- Private keys, secret keys +- TOTP seeds/secrets + +Return ONLY valid JSON array, no explanation. + +File content: +%s`, string(content)) + + llmResp, err := callLLM(h.Cfg, "You are a data parser. Parse password manager exports into structured JSON.", prompt) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed: "+err.Error()) + return + } + + // Parse LLM response + var entries []lib.VaultData + if err := json.Unmarshal([]byte(llmResp), &entries); err != nil { + // Try to extract JSON from response + start := strings.Index(llmResp, "[") + end := strings.LastIndex(llmResp, "]") + if start >= 0 && end > start { + if json.Unmarshal([]byte(llmResp[start:end+1]), &entries) != nil { + ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") + return + } + } else { + ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response") + return + } + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + Action: lib.ActionImport, + Actor: actor, + IPAddr: realIP(r), + Title: fmt.Sprintf("%d entries parsed", len(entries)), + }) + + JSONResponse(w, http.StatusOK, map[string]any{ + "entries": entries, + "count": len(entries), + }) +} + +// ImportConfirm confirms and saves imported entries. +func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) { + actor := ActorFromContext(r.Context()) + + var req struct { + Entries []lib.VaultData `json:"entries"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + + var created int + for _, vd := range req.Entries { + entry := &lib.Entry{ + Type: vd.Type, + Title: vd.Title, + DataLevel: lib.DataLevelL1, + VaultData: &vd, + } + if err := lib.EntryCreate(h.DB, h.Cfg, entry); err == nil { + created++ + } + } + + lib.AuditLog(h.DB, &lib.AuditEvent{ + Action: lib.ActionImport, + Actor: actor, + IPAddr: realIP(r), + Title: fmt.Sprintf("%d entries imported", created), + }) + + JSONResponse(w, http.StatusOK, map[string]int{"imported": created}) +} + +// --------------------------------------------------------------------------- +// Audit Log +// --------------------------------------------------------------------------- + +// GetAuditLog returns recent audit events. +func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) { + limitStr := r.URL.Query().Get("limit") + limit := 100 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + events, err := lib.AuditList(h.DB, limit) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list audit events") + return + } + if events == nil { + events = []lib.AuditEvent{} + } + + JSONResponse(w, http.StatusOK, events) +} + +// --------------------------------------------------------------------------- +// MCP Endpoint +// --------------------------------------------------------------------------- + +// MCPHandler handles JSON-RPC 2.0 MCP protocol requests. +func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) { + var req struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + mcpError(w, nil, -32700, "Parse error") + return + } + + if req.JSONRPC != "2.0" { + mcpError(w, req.ID, -32600, "Invalid Request") + return + } + + var result any + var err error + + switch req.Method { + case "tools/list": + result = h.mcpToolsList() + case "tools/call": + result, err = h.mcpToolsCall(r, req.Params) + default: + mcpError(w, req.ID, -32601, "Method not found") + return + } + + if err != nil { + mcpError(w, req.ID, -32000, err.Error()) + return + } + + mcpSuccess(w, req.ID, result) +} + +func (h *Handlers) mcpToolsList() map[string]any { + return map[string]any{ + "tools": []map[string]any{ + { + "name": "get_credential", + "description": "Search and return a credential from the vault. L2 fields are omitted.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]string{"type": "string", "description": "Search query (title or URL)"}, + }, + "required": []string{"query"}, + }, + }, + { + "name": "list_credentials", + "description": "List all credentials in the vault (titles, types, URLs only).", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "filter": map[string]string{"type": "string", "description": "Optional type filter"}, + }, + }, + }, + { + "name": "get_totp", + "description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]string{"type": "string", "description": "Entry title or ID"}, + }, + "required": []string{"query"}, + }, + }, + { + "name": "search_vault", + "description": "Search the vault by title.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]string{"type": "string", "description": "Search query"}, + }, + "required": []string{"query"}, + }, + }, + { + "name": "check_expiring", + "description": "Check for entries with expiring credentials.", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "days": map[string]any{"type": "number", "description": "Days to check (default 30)"}, + }, + }, + }, + }, + } +} + +func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) { + var call struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(params, &call); err != nil { + return nil, fmt.Errorf("invalid params") + } + + switch call.Name { + case "get_credential", "search_vault": + query, _ := call.Arguments["query"].(string) + if query == "" { + return nil, fmt.Errorf("query is required") + } + entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) + if err != nil { + return nil, err + } + // Strip L2 fields + for i := range entries { + if entries[i].VaultData != nil { + stripL2Fields(entries[i].VaultData) + } + } + if len(entries) == 0 { + return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil + } + // For get_credential, return best match + if call.Name == "get_credential" { + result, _ := json.Marshal(entries[0]) + return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil + } + result, _ := json.Marshal(entries) + return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil + + case "list_credentials": + filter, _ := call.Arguments["filter"].(string) + entries, err := lib.EntryList(h.DB, h.Cfg, nil) + if err != nil { + return nil, err + } + var list []map[string]any + for _, e := range entries { + if filter != "" && e.Type != filter { + continue + } + item := map[string]any{ + "entry_id": e.EntryID, + "title": e.Title, + "type": e.Type, + } + if e.VaultData != nil && len(e.VaultData.URLs) > 0 { + item["urls"] = e.VaultData.URLs + } + list = append(list, item) + } + result, _ := json.Marshal(list) + return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil + + case "get_totp": + query, _ := call.Arguments["query"].(string) + if query == "" { + return nil, fmt.Errorf("query is required") + } + entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query) + if err != nil || len(entries) == 0 { + return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil + } + entry := entries[0] + if entry.VaultData == nil { + return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil + } + for _, field := range entry.VaultData.Fields { + if field.Kind == "totp" { + if field.L2 { + return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil + } + seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", "")) + code, err := totp.GenerateCode(seed, time.Now()) + if err != nil { + return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil + } + now := time.Now().Unix() + expiresIn := 30 - (now % 30) + result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn) + return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil + } + } + return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil + + case "check_expiring": + daysF, _ := call.Arguments["days"].(float64) + days := int(daysF) + if days <= 0 { + days = 30 + } + entries, err := lib.EntryList(h.DB, h.Cfg, nil) + if err != nil { + return nil, err + } + cutoff := time.Now().AddDate(0, 0, days) + var expiring []map[string]any + for _, e := range entries { + if e.VaultData == nil || e.VaultData.Expires == "" { + continue + } + exp, err := time.Parse("2006-01-02", e.VaultData.Expires) + if err != nil { + continue + } + if exp.Before(cutoff) { + daysRemaining := int(exp.Sub(time.Now()).Hours() / 24) + expiring = append(expiring, map[string]any{ + "title": e.Title, + "type": e.Type, + "expires": e.VaultData.Expires, + "days_remaining": daysRemaining, + }) + } + } + result, _ := json.Marshal(expiring) + return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil + + default: + return nil, fmt.Errorf("unknown tool: %s", call.Name) + } +} + +func mcpSuccess(w http.ResponseWriter, id any, result any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) +} + +func mcpError(w http.ResponseWriter, id any, code int, message string) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": code, + "message": message, + }, + }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func stripL2Fields(vd *lib.VaultData) { + for i := range vd.Fields { + if vd.Fields[i].L2 { + vd.Fields[i].Value = "" + } + } +} + +func extractDomain(urlStr string) string { + // Simple domain extraction + urlStr = strings.TrimPrefix(urlStr, "https://") + urlStr = strings.TrimPrefix(urlStr, "http://") + urlStr = strings.TrimPrefix(urlStr, "www.") + if idx := strings.Index(urlStr, "/"); idx > 0 { + urlStr = urlStr[:idx] + } + if idx := strings.Index(urlStr, ":"); idx > 0 { + urlStr = urlStr[:idx] + } + return urlStr +} + +func callLLM(cfg *lib.Config, system, user string) (string, error) { + reqBody := map[string]any{ + "model": cfg.LLMModel, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + "max_tokens": 4096, + } + body, _ := json.Marshal(reqBody) + + req, _ := http.NewRequest("POST", "https://api.fireworks.ai/inference/v1/chat/completions", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+cfg.FireworksAPIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.Error.Message != "" { + return "", fmt.Errorf("LLM error: %s", result.Error.Message) + } + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response from LLM") + } + return result.Choices[0].Message.Content, nil +} + +// generateTOTPSecret generates a new TOTP secret. +func generateTOTPSecret() string { + b := make([]byte, 20) + rand.Read(b) + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) +} diff --git a/api/middleware.go b/api/middleware.go new file mode 100644 index 0000000..7f5e799 --- /dev/null +++ b/api/middleware.go @@ -0,0 +1,202 @@ +package api + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/johanj/clawvault/lib" +) + +type contextKey string + +const ( + ctxActor contextKey = "actor" + ctxSession contextKey = "session" +) + +// ActorFromContext returns the actor type from request context. +func ActorFromContext(ctx context.Context) string { + v, ok := ctx.Value(ctxActor).(string) + if !ok { + return lib.ActorWeb + } + return v +} + +// SessionFromContext returns the session from request context. +func SessionFromContext(ctx context.Context) *lib.Session { + v, _ := ctx.Value(ctxSession).(*lib.Session) + return v +} + +// AuthMiddleware validates Bearer tokens and sets session context. +func AuthMiddleware(db *lib.DB) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + ErrorResponse(w, http.StatusUnauthorized, "missing_token", "Authorization header required") + return + } + token := strings.TrimPrefix(auth, "Bearer ") + + session, err := lib.SessionGet(db, token) + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "session_error", "Session lookup failed") + return + } + if session == nil { + ErrorResponse(w, http.StatusUnauthorized, "invalid_token", "Invalid or expired token") + return + } + + ctx := context.WithValue(r.Context(), ctxActor, session.Actor) + ctx = context.WithValue(ctx, ctxSession, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// LoggingMiddleware logs HTTP requests. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrapped := &statusWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(wrapped, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, wrapped.status, time.Since(start)) + }) +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(code int) { + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +// RateLimitMiddleware implements per-IP rate limiting. +func RateLimitMiddleware(requestsPerMinute int) func(http.Handler) http.Handler { + var mu sync.Mutex + clients := make(map[string]*rateLimitEntry) + + go func() { + for { + time.Sleep(time.Minute) + mu.Lock() + now := time.Now() + for ip, entry := range clients { + if now.Sub(entry.windowStart) > time.Minute { + delete(clients, ip) + } + } + mu.Unlock() + } + }() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := realIP(r) + + mu.Lock() + entry, exists := clients[ip] + now := time.Now() + if !exists || now.Sub(entry.windowStart) > time.Minute { + entry = &rateLimitEntry{windowStart: now, count: 0} + clients[ip] = entry + } + entry.count++ + count := entry.count + mu.Unlock() + + if count > requestsPerMinute { + ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Too many requests") + return + } + + next.ServeHTTP(w, r) + }) + } +} + +type rateLimitEntry struct { + windowStart time.Time + count int +} + +// CORSMiddleware handles CORS headers. +func CORSMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Allow localhost and 127.0.0.1 for development + if origin != "" && (strings.Contains(origin, "localhost") || strings.Contains(origin, "127.0.0.1")) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// SecurityHeadersMiddleware adds security headers to all responses. +func SecurityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + // CSP allowing localhost and 127.0.0.1 for development + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' localhost 127.0.0.1") + + next.ServeHTTP(w, r) + }) +} + +// ErrorResponse sends a standard JSON error response. +func ErrorResponse(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": message, + "code": code, + }) +} + +// JSONResponse sends a standard JSON success response. +func JSONResponse(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func realIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + return strings.TrimSpace(parts[0]) + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + addr := r.RemoteAddr + if idx := strings.LastIndex(addr, ":"); idx != -1 { + return addr[:idx] + } + return addr +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..3a1f1dc --- /dev/null +++ b/api/routes.go @@ -0,0 +1,70 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/johanj/clawvault/lib" +) + +// NewRouter creates the main router with all routes registered. +func NewRouter(db *lib.DB, cfg *lib.Config, webFS embed.FS) *chi.Mux { + r := chi.NewRouter() + h := NewHandlers(db, cfg) + + // Global middleware + r.Use(LoggingMiddleware) + r.Use(CORSMiddleware) + r.Use(SecurityHeadersMiddleware) + r.Use(RateLimitMiddleware(120)) // 120 req/min per IP + + // Health check (unauthenticated) + r.Get("/health", h.Health) + + // Setup endpoint (creates initial session) + r.Post("/api/auth/setup", h.Setup) + + // API routes (authenticated) + r.Route("/api", func(r chi.Router) { + r.Use(AuthMiddleware(db)) + + // Entries CRUD + r.Get("/entries", h.ListEntries) + r.Post("/entries", h.CreateEntry) + r.Get("/entries/{id}", h.GetEntry) + r.Put("/entries/{id}", h.UpdateEntry) + r.Delete("/entries/{id}", h.DeleteEntry) + + // Search + r.Get("/search", h.SearchEntries) + + // Password generator + r.Get("/generate", h.GeneratePassword) + + // Import + r.Post("/import", h.ImportEntries) + r.Post("/import/confirm", h.ImportConfirm) + + // Audit log + r.Get("/audit", h.GetAuditLog) + + // Extension API + r.Get("/ext/totp/{id}", h.GetTOTP) + r.Get("/ext/match", h.MatchURL) + r.Post("/ext/map", h.MapFields) + }) + + // MCP endpoint (authenticated) + r.With(AuthMiddleware(db)).Post("/mcp", h.MCPHandler) + + // Embedded web UI + webRoot, err := fs.Sub(webFS, "web") + if err == nil { + fileServer := http.FileServer(http.FS(webRoot)) + r.Handle("/*", fileServer) + } + + return r +} diff --git a/cmd/clawvault/main.go b/cmd/clawvault/main.go new file mode 100644 index 0000000..ec603df --- /dev/null +++ b/cmd/clawvault/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "embed" + "log" + "net/http" + + "github.com/johanj/clawvault/api" + "github.com/johanj/clawvault/lib" +) + +//go:embed web +var webFS embed.FS + +func main() { + cfg, err := lib.LoadConfig() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := lib.OpenDB(cfg.DBPath) + if err != nil { + log.Fatalf("database: %v", err) + } + defer db.Close() + + if err := lib.MigrateDB(db); err != nil { + log.Fatalf("migration: %v", err) + } + + router := api.NewRouter(db, cfg, webFS) + + addr := ":" + cfg.Port + log.Printf("ClawVault starting on %s", addr) + if err := http.ListenAndServe(addr, router); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/cmd/clawvault/web/index.html b/cmd/clawvault/web/index.html new file mode 100644 index 0000000..db9408b --- /dev/null +++ b/cmd/clawvault/web/index.html @@ -0,0 +1,698 @@ + + + + + + ClawVault + + + + + +
+ + + + +
+ +
+ +
+ 🔓 L1 Active + + +
+
+ + +
+ +
+
+
+ + + + + + + + + + diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..7fc0b13 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,106 @@ +// ClawVault Background Service Worker + +// Get settings from storage +async function getSettings() { + const result = await chrome.storage.local.get(['vaultUrl', 'apiToken']); + return { + vaultUrl: result.vaultUrl || 'http://localhost:8765', + apiToken: result.apiToken || '' + }; +} + +// API call helper +async function apiCall(method, path, body) { + const settings = await getSettings(); + if (!settings.apiToken) { + throw new Error('Not configured'); + } + + const opts = { + method, + headers: { + 'Authorization': 'Bearer ' + settings.apiToken, + 'Content-Type': 'application/json' + } + }; + if (body) { + opts.body = JSON.stringify(body); + } + + const res = await fetch(settings.vaultUrl + path, opts); + if (!res.ok) { + throw new Error('API error: ' + res.status); + } + return res.json(); +} + +// Handle messages from popup and content scripts +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'getMatches') { + apiCall('GET', '/api/ext/match?url=' + encodeURIComponent(request.url)) + .then(matches => sendResponse({ success: true, matches })) + .catch(err => sendResponse({ success: false, error: err.message })); + return true; // async response + } + + if (request.action === 'getEntry') { + apiCall('GET', '/api/entries/' + request.id) + .then(entry => sendResponse({ success: true, entry })) + .catch(err => sendResponse({ success: false, error: err.message })); + return true; + } + + if (request.action === 'getTOTP') { + apiCall('GET', '/api/ext/totp/' + request.id) + .then(data => sendResponse({ success: true, data })) + .catch(err => sendResponse({ success: false, error: err.message })); + return true; + } + + if (request.action === 'mapFields') { + apiCall('POST', '/api/ext/map', request.data) + .then(mapping => sendResponse({ success: true, mapping })) + .catch(err => sendResponse({ success: false, error: err.message })); + return true; + } + + if (request.action === 'fill') { + // Relay fill request to content script + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, { + action: 'fillFields', + fields: request.fields + }); + } + }); + sendResponse({ success: true }); + return true; + } + + if (request.action === 'getSettings') { + getSettings().then(settings => sendResponse({ success: true, settings })); + return true; + } + + if (request.action === 'saveSettings') { + chrome.storage.local.set({ + vaultUrl: request.vaultUrl, + apiToken: request.apiToken + }).then(() => sendResponse({ success: true })); + return true; + } +}); + +// Listen for form detection from content scripts +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'formsDetected') { + // Update badge with form count + if (request.count > 0) { + chrome.action.setBadgeText({ text: String(request.count), tabId: sender.tab.id }); + chrome.action.setBadgeBackgroundColor({ color: '#c9a84c', tabId: sender.tab.id }); + } else { + chrome.action.setBadgeText({ text: '', tabId: sender.tab.id }); + } + } +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..c4be447 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,124 @@ +// ClawVault Content Script + +// Detect login forms and notify background +function detectForms() { + const forms = document.querySelectorAll('form'); + let loginForms = 0; + + forms.forEach(form => { + const hasPassword = form.querySelector('input[type="password"]'); + const hasUsername = form.querySelector('input[type="text"], input[type="email"], input[name*="user"], input[name*="email"], input[name*="login"]'); + + if (hasPassword || hasUsername) { + loginForms++; + } + }); + + // Also check for standalone password fields + const standalonePasswords = document.querySelectorAll('input[type="password"]:not(form input)'); + loginForms += standalonePasswords.length; + + chrome.runtime.sendMessage({ action: 'formsDetected', count: loginForms }); + + return loginForms; +} + +// Get all form fields for mapping +function getFormFields() { + const fields = []; + const inputs = document.querySelectorAll('input:not([type="hidden"]):not([type="submit"]):not([type="button"])'); + + inputs.forEach(input => { + const label = findLabel(input); + fields.push({ + selector: getSelector(input), + label: label, + type: input.type, + name: input.name, + placeholder: input.placeholder, + autocomplete: input.autocomplete + }); + }); + + return fields; +} + +// Find label for an input +function findLabel(input) { + // Check for associated label + if (input.id) { + const label = document.querySelector(`label[for="${input.id}"]`); + if (label) return label.textContent.trim(); + } + + // Check for wrapping label + const parent = input.closest('label'); + if (parent) { + const text = parent.textContent.replace(input.value, '').trim(); + if (text) return text; + } + + // Check aria-label + if (input.getAttribute('aria-label')) { + return input.getAttribute('aria-label'); + } + + // Use placeholder or name as fallback + return input.placeholder || input.name || input.type; +} + +// Generate a unique selector for an element +function getSelector(el) { + if (el.id) return '#' + el.id; + if (el.name) return `[name="${el.name}"]`; + + // Build a path selector + const path = []; + while (el && el !== document.body) { + let selector = el.tagName.toLowerCase(); + if (el.className) { + selector += '.' + el.className.trim().split(/\s+/).join('.'); + } + path.unshift(selector); + el = el.parentElement; + } + return path.join(' > '); +} + +// Fill fields by selector +function fillFields(fields) { + Object.entries(fields).forEach(([label, selector]) => { + try { + const el = document.querySelector(selector); + if (el) { + el.value = label; // label here is actually the value + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + } + } catch (e) { + console.error('ClawVault: Failed to fill field', selector, e); + } + }); +} + +// Listen for messages from background +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'fillFields') { + fillFields(request.fields); + sendResponse({ success: true }); + } + + if (request.action === 'getFormFields') { + const fields = getFormFields(); + sendResponse({ success: true, fields }); + } +}); + +// Detect forms on page load +setTimeout(detectForms, 500); + +// Re-detect on dynamic content changes +const observer = new MutationObserver(() => { + detectForms(); +}); +observer.observe(document.body, { childList: true, subtree: true }); diff --git a/extension/icon128.png b/extension/icon128.png new file mode 100644 index 0000000..f5d8a60 Binary files /dev/null and b/extension/icon128.png differ diff --git a/extension/icon16.png b/extension/icon16.png new file mode 100644 index 0000000..e7fa471 Binary files /dev/null and b/extension/icon16.png differ diff --git a/extension/icon48.png b/extension/icon48.png new file mode 100644 index 0000000..e48e0e1 Binary files /dev/null and b/extension/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..d208acd --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "ClawVault", + "version": "0.1.0", + "description": "ClawVault password manager extension", + "permissions": ["activeTab", "storage", "scripting"], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + }, + "background": { + "service_worker": "background.js" + }, + "content_scripts": [{ + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + }], + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..9ef4134 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,139 @@ + + + + + + + +
+ 🔐 +

ClawVault

+
+ +
+
+
+ ⚙️ Settings +
+ +
+
+ + + + + + +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..320b582 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,174 @@ +// ClawVault Popup Script + +let currentUrl = ''; +let currentMatches = []; + +// Get current tab URL +async function getCurrentUrl() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]) { + currentUrl = tabs[0].url; + document.getElementById('currentUrl').textContent = new URL(currentUrl).hostname; + return currentUrl; + } + return ''; +} + +// Load matching credentials +async function loadMatches() { + const url = await getCurrentUrl(); + if (!url) return; + + const matchesDiv = document.getElementById('matches'); + matchesDiv.innerHTML = '
Loading...
'; + + chrome.runtime.sendMessage({ action: 'getMatches', url }, (response) => { + if (chrome.runtime.lastError || !response || !response.success) { + matchesDiv.innerHTML = '
Not connected to vault. Check settings.
'; + return; + } + + currentMatches = response.matches; + + if (!currentMatches || currentMatches.length === 0) { + matchesDiv.innerHTML = '
No matching credentials
'; + return; + } + + let html = ''; + currentMatches.forEach((entry, idx) => { + const hasL2 = entry.data && entry.data.fields && entry.data.fields.some(f => f.l2); + html += `
+
${escapeHtml(entry.title)}
+ +
`; + }); + + matchesDiv.innerHTML = html; + + // Add click handlers + document.querySelectorAll('.entry').forEach(el => { + el.addEventListener('click', () => fillEntry(parseInt(el.dataset.idx))); + }); + }); +} + +// Fill an entry into the page +async function fillEntry(idx) { + const entry = currentMatches[idx]; + if (!entry || !entry.data || !entry.data.fields) return; + + // Get form fields from page + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + chrome.tabs.sendMessage(tabs[0].id, { action: 'getFormFields' }, (response) => { + if (chrome.runtime.lastError || !response || !response.success) { + // Fallback: try simple fill + simpleFill(entry); + return; + } + + // Request LLM mapping + chrome.runtime.sendMessage({ + action: 'mapFields', + data: { + entry_id: entry.entry_id, + page_fields: response.fields + } + }, (mapResponse) => { + if (mapResponse && mapResponse.success && mapResponse.mapping) { + // Convert mapping to actual values + const fields = {}; + Object.entries(mapResponse.mapping).forEach(([label, selector]) => { + const field = entry.data.fields.find(f => f.label === label); + if (field && !field.l2) { + fields[selector] = field.value; + } + }); + + chrome.runtime.sendMessage({ action: 'fill', fields }); + } else { + simpleFill(entry); + } + }); + }); + }); + + window.close(); +} + +// Simple fill without LLM mapping +function simpleFill(entry) { + const fields = {}; + entry.data.fields.forEach(f => { + if (f.l2) return; + + if (f.label.toLowerCase().includes('user') || f.label.toLowerCase().includes('email')) { + fields['input[type="email"], input[type="text"], input[name*="user"], input[name*="email"]'] = f.value; + } + if (f.label.toLowerCase().includes('password') || f.kind === 'password') { + fields['input[type="password"]'] = f.value; + } + }); + + chrome.runtime.sendMessage({ action: 'fill', fields }); +} + +// Settings handlers +document.getElementById('settingsLink').addEventListener('click', (e) => { + e.preventDefault(); + document.getElementById('matchView').style.display = 'none'; + document.getElementById('settingsView').classList.add('active'); + loadSettings(); +}); + +document.getElementById('backToMatches').addEventListener('click', () => { + document.getElementById('settingsView').classList.remove('active'); + document.getElementById('matchView').style.display = 'block'; +}); + +document.getElementById('saveSettings').addEventListener('click', saveSettings); + +async function loadSettings() { + chrome.runtime.sendMessage({ action: 'getSettings' }, (response) => { + if (response && response.success) { + document.getElementById('vaultUrl').value = response.settings.vaultUrl || ''; + document.getElementById('apiToken').value = response.settings.apiToken || ''; + } + }); +} + +async function saveSettings() { + const vaultUrl = document.getElementById('vaultUrl').value.trim(); + const apiToken = document.getElementById('apiToken').value.trim(); + + chrome.runtime.sendMessage({ + action: 'saveSettings', + vaultUrl, + apiToken + }, (response) => { + const status = document.getElementById('settingsStatus'); + if (response && response.success) { + status.className = 'status success'; + status.textContent = 'Settings saved!'; + setTimeout(() => { + document.getElementById('settingsView').classList.remove('active'); + document.getElementById('matchView').style.display = 'block'; + loadMatches(); + }, 1000); + } else { + status.className = 'status error'; + status.textContent = 'Failed to save settings'; + } + }); +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>'); +} + +// Initialize +loadMatches(); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c82b8e --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/johanj/clawvault + +go 1.24.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/klauspost/compress v1.18.4 + github.com/mattn/go-sqlite3 v1.14.34 + github.com/pquerna/otp v1.5.0 + golang.org/x/crypto v0.48.0 +) + +require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6cb2968 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= diff --git a/lib/config.go b/lib/config.go new file mode 100644 index 0000000..16100d1 --- /dev/null +++ b/lib/config.go @@ -0,0 +1,59 @@ +package lib + +import ( + "encoding/hex" + "fmt" + "os" +) + +// Config holds application configuration. +type Config struct { + VaultKey []byte // decoded from VAULT_KEY hex env + Port string // default "8765" + DBPath string // default "./clawvault.db" + FireworksAPIKey string + LLMModel string // default llama-v3p3-70b-instruct + SessionTTL int64 // default 86400 (24 hours) +} + +// LoadConfig loads configuration from environment variables. +func LoadConfig() (*Config, error) { + vaultKeyHex := os.Getenv("VAULT_KEY") + if vaultKeyHex == "" { + return nil, fmt.Errorf("VAULT_KEY environment variable required") + } + vaultKey, err := hex.DecodeString(vaultKeyHex) + if err != nil { + return nil, fmt.Errorf("VAULT_KEY must be hex: %w", err) + } + if len(vaultKey) != 32 { + return nil, fmt.Errorf("VAULT_KEY must be 32 bytes (64 hex chars)") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8765" + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./clawvault.db" + } + + fireworksKey := os.Getenv("FIREWORKS_API_KEY") + llmModel := os.Getenv("LLM_MODEL") + if llmModel == "" { + llmModel = "accounts/fireworks/models/llama-v3p3-70b-instruct" + } + + sessionTTL := int64(86400) // 24 hours default + + return &Config{ + VaultKey: vaultKey, + Port: port, + DBPath: dbPath, + FireworksAPIKey: fireworksKey, + LLMModel: llmModel, + SessionTTL: sessionTTL, + }, nil +} diff --git a/lib/crypto.go b/lib/crypto.go new file mode 100644 index 0000000..56a7f63 --- /dev/null +++ b/lib/crypto.go @@ -0,0 +1,136 @@ +package lib + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "errors" + "io" + + "github.com/klauspost/compress/zstd" + "golang.org/x/crypto/hkdf" +) + +var ( + ErrDecryptionFailed = errors.New("decryption failed") + ErrInvalidCiphertext = errors.New("invalid ciphertext") +) + +// DeriveEntryKey derives a per-entry AES-256 key from the vault key using HKDF-SHA256. +func DeriveEntryKey(vaultKey []byte, entryID string) ([]byte, error) { + info := []byte("clawvault-entry-" + entryID) + reader := hkdf.New(sha256.New, vaultKey, nil, info) + key := make([]byte, 32) // AES-256 + if _, err := io.ReadFull(reader, key); err != nil { + return nil, err + } + return key, nil +} + +// DeriveHMACKey derives a separate HMAC key for blind indexes. +func DeriveHMACKey(vaultKey []byte) ([]byte, error) { + info := []byte("clawvault-hmac-index") + reader := hkdf.New(sha256.New, vaultKey, nil, info) + key := make([]byte, 32) + if _, err := io.ReadFull(reader, key); err != nil { + return nil, err + } + return key, nil +} + +// BlindIndex computes an HMAC-SHA256 blind index for searchable encrypted fields. +// Returns truncated hash (16 bytes) for storage efficiency. +func BlindIndex(hmacKey []byte, plaintext string) []byte { + h := hmac.New(sha256.New, hmacKey) + h.Write([]byte(plaintext)) + return h.Sum(nil)[:16] // truncate to 16 bytes +} + +// Pack compresses with zstd then encrypts with AES-256-GCM (random nonce). +func Pack(key []byte, plaintext string) ([]byte, error) { + compressed, err := zstdCompress([]byte(plaintext)) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, compressed, nil), nil +} + +// Unpack decrypts AES-256-GCM then decompresses zstd. +func Unpack(key []byte, ciphertext []byte) (string, error) { + if len(ciphertext) == 0 { + return "", nil + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return "", ErrInvalidCiphertext + } + + nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:] + compressed, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", ErrDecryptionFailed + } + + decompressed, err := zstdDecompress(compressed) + if err != nil { + return "", err + } + + return string(decompressed), nil +} + +// zstd encoder/decoder (reusable, goroutine-safe) +var ( + zstdEncoder, _ = zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedDefault)) + zstdDecoder, _ = zstd.NewReader(nil) +) + +func zstdCompress(data []byte) ([]byte, error) { + return zstdEncoder.EncodeAll(data, nil), nil +} + +func zstdDecompress(data []byte) ([]byte, error) { + return zstdDecoder.DecodeAll(data, nil) +} + +// GenerateToken generates a random hex token (32 bytes = 64 hex chars). +func GenerateToken() string { + b := make([]byte, 32) + rand.Read(b) + const hex = "0123456789abcdef" + result := make([]byte, 64) + for i, v := range b { + result[i*2] = hex[v>>4] + result[i*2+1] = hex[v&0x0f] + } + return string(result) +} diff --git a/lib/dbcore.go b/lib/dbcore.go new file mode 100644 index 0000000..5cfc8ed --- /dev/null +++ b/lib/dbcore.go @@ -0,0 +1,469 @@ +package lib + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" +) + +var ( + ErrNotFound = errors.New("not found") + ErrVersionConflict = errors.New("version conflict: entry was modified") +) + +const schema = ` +CREATE TABLE IF NOT EXISTS entries ( + entry_id TEXT PRIMARY KEY, + parent_id TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL, + title TEXT NOT NULL, + title_idx BLOB NOT NULL, + data BLOB NOT NULL, + data_level INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + deleted_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id); +CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type); +CREATE INDEX IF NOT EXISTS idx_entries_title_idx ON entries(title_idx); +CREATE INDEX IF NOT EXISTS idx_entries_deleted ON entries(deleted_at); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + actor TEXT NOT NULL DEFAULT 'web' +); + +CREATE TABLE IF NOT EXISTS audit_log ( + event_id TEXT PRIMARY KEY, + entry_id TEXT, + title TEXT, + action TEXT NOT NULL, + actor TEXT NOT NULL, + ip_addr TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_entry ON audit_log(entry_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); + +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + cred_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + public_key BLOB NOT NULL, + prf_salt BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); +` + +// OpenDB opens the SQLite database. +func OpenDB(dbPath string) (*DB, error) { + conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + if err := conn.Ping(); err != nil { + return nil, fmt.Errorf("ping db: %w", err) + } + return &DB{Conn: conn}, nil +} + +// MigrateDB runs the schema migrations. +func MigrateDB(db *DB) error { + _, err := db.Conn.Exec(schema) + return err +} + +// Close closes the database connection. +func (db *DB) Close() error { + return db.Conn.Close() +} + +// --------------------------------------------------------------------------- +// Entry operations +// --------------------------------------------------------------------------- + +// EntryCreate creates a new entry. +func EntryCreate(db *DB, cfg *Config, e *Entry) error { + if e.EntryID == "" { + e.EntryID = uuid.New().String() + } + + now := time.Now().UnixMilli() + e.CreatedAt = now + e.UpdatedAt = now + e.Version = 1 + if e.DataLevel == 0 { + e.DataLevel = DataLevelL1 + } + + // Derive keys and encrypt + entryKey, err := DeriveEntryKey(cfg.VaultKey, e.EntryID) + if err != nil { + return err + } + hmacKey, err := DeriveHMACKey(cfg.VaultKey) + if err != nil { + return err + } + + // Create blind index for title + e.TitleIdx = BlindIndex(hmacKey, strings.ToLower(e.Title)) + + // Pack VaultData if present + if e.VaultData != nil { + dataJSON, err := json.Marshal(e.VaultData) + if err != nil { + return err + } + packed, err := Pack(entryKey, string(dataJSON)) + if err != nil { + return err + } + e.Data = packed + } + + _, err = db.Conn.Exec( + `INSERT INTO entries (entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.EntryID, e.ParentID, e.Type, e.Title, e.TitleIdx, e.Data, e.DataLevel, e.CreatedAt, e.UpdatedAt, e.Version, + ) + return err +} + +// EntryGet retrieves an entry by ID. +func EntryGet(db *DB, cfg *Config, entryID string) (*Entry, error) { + var e Entry + var deletedAt sql.NullInt64 + err := db.Conn.QueryRow( + `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version, deleted_at + FROM entries WHERE entry_id = ?`, entryID, + ).Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.CreatedAt, &e.UpdatedAt, &e.Version, &deletedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if deletedAt.Valid { + v := deletedAt.Int64 + e.DeletedAt = &v + } + + // Unpack data + if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { + entryKey, err := DeriveEntryKey(cfg.VaultKey, e.EntryID) + if err != nil { + return nil, err + } + dataText, err := Unpack(entryKey, e.Data) + if err != nil { + return nil, err + } + var vd VaultData + if err := json.Unmarshal([]byte(dataText), &vd); err != nil { + return nil, err + } + e.VaultData = &vd + } + + return &e, nil +} + +// EntryUpdate updates an existing entry with optimistic locking. +func EntryUpdate(db *DB, cfg *Config, e *Entry) error { + now := time.Now().UnixMilli() + + // Derive keys + entryKey, err := DeriveEntryKey(cfg.VaultKey, e.EntryID) + if err != nil { + return err + } + hmacKey, err := DeriveHMACKey(cfg.VaultKey) + if err != nil { + return err + } + + // Update blind index + e.TitleIdx = BlindIndex(hmacKey, strings.ToLower(e.Title)) + + // Pack VaultData if present + if e.VaultData != nil { + dataJSON, err := json.Marshal(e.VaultData) + if err != nil { + return err + } + packed, err := Pack(entryKey, string(dataJSON)) + if err != nil { + return err + } + e.Data = packed + } + + result, err := db.Conn.Exec( + `UPDATE entries SET parent_id=?, type=?, title=?, title_idx=?, data=?, data_level=?, updated_at=?, version=version+1 + WHERE entry_id = ? AND version = ? AND deleted_at IS NULL`, + e.ParentID, e.Type, e.Title, e.TitleIdx, e.Data, e.DataLevel, now, + e.EntryID, e.Version, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrVersionConflict + } + e.Version++ + e.UpdatedAt = now + return nil +} + +// EntryDelete soft-deletes an entry. +func EntryDelete(db *DB, entryID string) error { + now := time.Now().UnixMilli() + result, err := db.Conn.Exec( + `UPDATE entries SET deleted_at = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`, + now, now, entryID, + ) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// EntryList returns all non-deleted entries, optionally filtered by parent. +func EntryList(db *DB, cfg *Config, parentID *string) ([]Entry, error) { + var rows *sql.Rows + var err error + + if parentID != nil { + rows, err = db.Conn.Query( + `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version + FROM entries WHERE deleted_at IS NULL AND parent_id = ? ORDER BY type, title`, *parentID, + ) + } else { + rows, err = db.Conn.Query( + `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version + FROM entries WHERE deleted_at IS NULL ORDER BY type, title`, + ) + } + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []Entry + for rows.Next() { + var e Entry + if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { + return nil, err + } + // Unpack L1 data + if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { + entryKey, err := DeriveEntryKey(cfg.VaultKey, e.EntryID) + if err == nil { + dataText, err := Unpack(entryKey, e.Data) + if err == nil { + var vd VaultData + if json.Unmarshal([]byte(dataText), &vd) == nil { + e.VaultData = &vd + } + } + } + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// EntrySearch searches entries by title (blind index lookup). +func EntrySearch(db *DB, cfg *Config, query string) ([]Entry, error) { + hmacKey, err := DeriveHMACKey(cfg.VaultKey) + if err != nil { + return nil, err + } + idx := BlindIndex(hmacKey, strings.ToLower(query)) + + rows, err := db.Conn.Query( + `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version + FROM entries WHERE deleted_at IS NULL AND title_idx = ? ORDER BY title`, idx, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []Entry + for rows.Next() { + var e Entry + if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { + return nil, err + } + if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { + entryKey, _ := DeriveEntryKey(cfg.VaultKey, e.EntryID) + dataText, _ := Unpack(entryKey, e.Data) + var vd VaultData + if json.Unmarshal([]byte(dataText), &vd) == nil { + e.VaultData = &vd + } + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// EntrySearchFuzzy searches entries by title using LIKE (less secure but more practical). +func EntrySearchFuzzy(db *DB, cfg *Config, query string) ([]Entry, error) { + rows, err := db.Conn.Query( + `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, created_at, updated_at, version + FROM entries WHERE deleted_at IS NULL AND title LIKE ? ORDER BY title`, "%"+query+"%", + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []Entry + for rows.Next() { + var e Entry + if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { + return nil, err + } + if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { + entryKey, _ := DeriveEntryKey(cfg.VaultKey, e.EntryID) + dataText, _ := Unpack(entryKey, e.Data) + var vd VaultData + if json.Unmarshal([]byte(dataText), &vd) == nil { + e.VaultData = &vd + } + } + entries = append(entries, e) + } + return entries, rows.Err() +} + +// --------------------------------------------------------------------------- +// Session operations +// --------------------------------------------------------------------------- + +// SessionCreate creates a new session. +func SessionCreate(db *DB, ttl int64, actor string) (*Session, error) { + now := time.Now().UnixMilli() + s := &Session{ + Token: GenerateToken(), + CreatedAt: now, + ExpiresAt: now + (ttl * 1000), + Actor: actor, + } + _, err := db.Conn.Exec( + `INSERT INTO sessions (token, created_at, expires_at, actor) VALUES (?, ?, ?, ?)`, + s.Token, s.CreatedAt, s.ExpiresAt, s.Actor, + ) + return s, err +} + +// SessionGet retrieves a session by token. +func SessionGet(db *DB, token string) (*Session, error) { + var s Session + err := db.Conn.QueryRow( + `SELECT token, created_at, expires_at, actor FROM sessions WHERE token = ?`, token, + ).Scan(&s.Token, &s.CreatedAt, &s.ExpiresAt, &s.Actor) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + // Check expiry + if s.ExpiresAt < time.Now().UnixMilli() { + return nil, nil + } + return &s, nil +} + +// SessionDelete deletes a session. +func SessionDelete(db *DB, token string) error { + _, err := db.Conn.Exec(`DELETE FROM sessions WHERE token = ?`, token) + return err +} + +// --------------------------------------------------------------------------- +// Audit operations +// --------------------------------------------------------------------------- + +// AuditLog records an audit event. +func AuditLog(db *DB, ev *AuditEvent) error { + if ev.EventID == "" { + ev.EventID = uuid.New().String() + } + if ev.CreatedAt == 0 { + ev.CreatedAt = time.Now().UnixMilli() + } + _, err := db.Conn.Exec( + `INSERT INTO audit_log (event_id, entry_id, title, action, actor, ip_addr, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ev.EventID, ev.EntryID, ev.Title, ev.Action, ev.Actor, ev.IPAddr, ev.CreatedAt, + ) + return err +} + +// AuditList returns recent audit events. +func AuditList(db *DB, limit int) ([]AuditEvent, error) { + if limit <= 0 { + limit = 100 + } + rows, err := db.Conn.Query( + `SELECT event_id, entry_id, title, action, actor, ip_addr, created_at + FROM audit_log ORDER BY created_at DESC LIMIT ?`, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []AuditEvent + for rows.Next() { + var ev AuditEvent + var entryID, title, ipAddr sql.NullString + if err := rows.Scan(&ev.EventID, &entryID, &title, &ev.Action, &ev.Actor, &ipAddr, &ev.CreatedAt); err != nil { + return nil, err + } + if entryID.Valid { + ev.EntryID = entryID.String + } + if title.Valid { + ev.Title = title.String + } + if ipAddr.Valid { + ev.IPAddr = ipAddr.String + } + events = append(events, ev) + } + return events, rows.Err() +} + +// EntryCount returns total entry count (for health check). +func EntryCount(db *DB) (int, error) { + var count int + err := db.Conn.QueryRow(`SELECT COUNT(*) FROM entries WHERE deleted_at IS NULL`).Scan(&count) + return count, err +} diff --git a/lib/types.go b/lib/types.go new file mode 100644 index 0000000..85aba82 --- /dev/null +++ b/lib/types.go @@ -0,0 +1,121 @@ +package lib + +import "database/sql" + +// VaultField represents a single field within a vault entry. +type VaultField struct { + Label string `json:"label"` + Value string `json:"value"` + Kind string `json:"kind"` // text|password|totp|url|file + Section string `json:"section,omitempty"` + L2 bool `json:"l2,omitempty"` // true = client-side decrypt only +} + +// VaultFile represents an attached file. +type VaultFile struct { + Name string `json:"name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + Data []byte `json:"data"` +} + +// VaultData is the JSON structure 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"` +} + +// Entry is the core data model — single table for all vault items. +type Entry struct { + EntryID string `json:"entry_id"` + ParentID string `json:"parent_id"` // folder entry_id, or "" for root + Type string `json:"type"` // credential|note|identity|card|ssh_key|totp|folder|custom + Title string `json:"title"` // plaintext for UI + TitleIdx []byte `json:"-"` // HMAC-SHA256 blind index for search + Data []byte `json:"-"` // packed: zstd + AES-256-GCM + DataLevel int `json:"data_level"` // 1=L1, 2=L2 + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Version int `json:"version"` // optimistic locking + DeletedAt *int64 `json:"deleted_at,omitempty"` + + // Unpacked field (not stored directly, populated after decrypt) + VaultData *VaultData `json:"data,omitempty"` +} + +// Session represents an authenticated session. +type Session struct { + Token string `json:"token"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + Actor string `json:"actor"` // web|extension|mcp +} + +// AuditEvent represents a security audit log entry. +type AuditEvent struct { + EventID string `json:"event_id"` + EntryID string `json:"entry_id,omitempty"` + Title string `json:"title,omitempty"` // snapshot of entry title + Action string `json:"action"` // read|fill|ai_read|create|update|delete|import|export + Actor string `json:"actor"` // web|extension|mcp + IPAddr string `json:"ip_addr,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +// WebAuthnCredential stores a registered WebAuthn credential. +type WebAuthnCredential struct { + CredID string `json:"cred_id"` + Name string `json:"name"` + PublicKey []byte `json:"public_key"` + PRFSalt []byte `json:"prf_salt"` + SignCount int `json:"sign_count"` + CreatedAt int64 `json:"created_at"` +} + +// DB wraps the database connection. +type DB struct { + Conn *sql.DB +} + +// Entry types +const ( + TypeCredential = "credential" + TypeCard = "card" + TypeIdentity = "identity" + TypeNote = "note" + TypeSSHKey = "ssh_key" + TypeTOTP = "totp" + TypeFolder = "folder" + TypeCustom = "custom" +) + +// Data levels +const ( + DataLevelL1 = 1 // Server-side encrypted (AI-readable) + DataLevelL2 = 2 // Client-side only (WebAuthn PRF) +) + +// Actor types +const ( + ActorWeb = "web" + ActorExtension = "extension" + ActorMCP = "mcp" +) + +// Action types +const ( + ActionRead = "read" + ActionFill = "fill" + ActionAIRead = "ai_read" + ActionCreate = "create" + ActionUpdate = "update" + ActionDelete = "delete" + ActionImport = "import" + ActionExport = "export" +)