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
This commit is contained in:
James (AI) 2026-02-28 15:42:48 -05:00
commit 0ff6db74cb
22 changed files with 3908 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -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/

53
README.md Normal file
View File

@ -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

388
SPEC.md Normal file
View File

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

1046
api/handlers.go Normal file

File diff suppressed because it is too large Load Diff

202
api/middleware.go Normal file
View File

@ -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
}

70
api/routes.go Normal file
View File

@ -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
}

38
cmd/clawvault/main.go Normal file
View File

@ -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)
}
}

View File

@ -0,0 +1,698 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClawVault</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
navy: {
900: '#0a0f1a',
800: '#111827',
700: '#1f2937',
600: '#374151',
},
gold: '#c9a84c',
}
}
}
}
</script>
<style>
.password-masked { font-family: 'Courier New', monospace; letter-spacing: 0.1em; }
.toast { animation: slideIn 0.3s ease; }
@keyframes slideIn { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
</style>
</head>
<body class="h-full bg-navy-900 text-gray-100">
<div id="app" class="h-full flex">
<!-- Sidebar -->
<aside class="w-64 bg-navy-800 border-r border-navy-600 flex flex-col">
<div class="p-4 border-b border-navy-600">
<h1 class="text-xl font-bold text-gold">🔐 ClawVault</h1>
</div>
<!-- Search -->
<div class="p-3">
<input type="text" id="searchBox" placeholder="Search vault..."
class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded text-sm focus:outline-none focus:border-gold">
</div>
<!-- Folder tree -->
<nav id="folderTree" class="flex-1 overflow-y-auto p-3">
<div class="text-sm text-gray-400">Loading...</div>
</nav>
<!-- Bottom actions -->
<div class="p-3 border-t border-navy-600 space-y-2">
<button onclick="showNewEntry()" class="w-full px-3 py-2 bg-gold text-navy-900 rounded font-medium hover:bg-yellow-400">
+ New Entry
</button>
<button onclick="showImport()" class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded text-sm hover:bg-navy-600">
Import
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 flex flex-col overflow-hidden">
<!-- Top nav -->
<header class="h-14 bg-navy-800 border-b border-navy-600 flex items-center justify-between px-4">
<div id="breadcrumb" class="text-sm text-gray-400">All Items</div>
<div class="flex items-center gap-3">
<span id="lockStatus" class="text-sm text-gray-400">🔓 L1 Active</span>
<button onclick="showAudit()" class="text-sm text-gray-400 hover:text-white">Audit Log</button>
<button onclick="logout()" class="text-sm text-gray-400 hover:text-white">Logout</button>
</div>
</header>
<!-- Content area -->
<div id="content" class="flex-1 overflow-y-auto p-6">
<!-- Entry list or detail view -->
</div>
</main>
</div>
<!-- Modals -->
<div id="modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div id="modalContent" class="bg-navy-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
</div>
</div>
<!-- Toast -->
<div id="toast" class="hidden fixed top-4 right-4 px-4 py-2 bg-green-600 text-white rounded shadow-lg z-50"></div>
<script>
let token = localStorage.getItem('clawvault_token');
let entries = [];
let currentEntry = null;
// API helpers
async function api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (res.status === 401) {
token = null;
localStorage.removeItem('clawvault_token');
showSetup();
throw new Error('Unauthorized');
}
return res.json();
}
function toast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast fixed top-4 right-4 px-4 py-2 rounded shadow-lg z-50 ' + (type === 'error' ? 'bg-red-600' : 'bg-green-600') + ' text-white';
setTimeout(() => t.classList.add('hidden'), 3000);
}
// Setup / Auth
async function showSetup() {
document.getElementById('content').innerHTML =
'<div class="max-w-md mx-auto mt-20 text-center">' +
'<h2 class="text-2xl font-bold mb-4">Welcome to ClawVault</h2>' +
'<p class="text-gray-400 mb-6">Your personal vault is ready.</p>' +
'<button onclick="doSetup()" class="px-6 py-3 bg-gold text-navy-900 rounded font-medium hover:bg-yellow-400">' +
'Initialize Vault' +
'</button>' +
'</div>';
}
async function doSetup() {
try {
const data = await api('POST', '/api/auth/setup');
token = data.token;
localStorage.setItem('clawvault_token', token);
toast('Vault initialized!');
loadEntries();
} catch (e) {
toast('Setup failed: ' + e.message, 'error');
}
}
function logout() {
token = null;
localStorage.removeItem('clawvault_token');
showSetup();
}
// Entry list
async function loadEntries() {
try {
entries = await api('GET', '/api/entries');
renderEntryList();
renderFolderTree();
} catch (e) {
if (e.message !== 'Unauthorized') {
toast('Failed to load entries', 'error');
}
}
}
function renderEntryList(parentId) {
parentId = parentId || '';
const filtered = entries.filter(function(e) { return e.parent_id === parentId; });
const content = document.getElementById('content');
if (filtered.length === 0) {
content.innerHTML =
'<div class="text-center text-gray-400 mt-20">' +
'<div class="text-4xl mb-4">🔐</div>' +
'<p>No entries yet. Create your first one!</p>' +
'</div>';
return;
}
var html = '<div class="grid gap-3">';
filtered.forEach(function(e) {
html += '<div onclick="showEntry(\'' + e.entry_id + '\')" ' +
'class="p-4 bg-navy-800 rounded-lg border border-navy-600 hover:border-gold cursor-pointer flex items-center gap-4">' +
'<div class="text-2xl">' + getTypeIcon(e.type) + '</div>' +
'<div class="flex-1">' +
'<div class="font-medium">' + escapeHtml(e.title) + '</div>' +
'<div class="text-sm text-gray-400">' + e.type + (e.data && e.data.urls && e.data.urls.length ? ' • ' + e.data.urls[0] : '') + '</div>' +
'</div>' +
(hasL2Fields(e) ? '<span class="text-sm text-amber-400">🔒 L2</span>' : '') +
'</div>';
});
html += '</div>';
content.innerHTML = html;
}
function renderFolderTree() {
var folders = entries.filter(function(e) { return e.type === 'folder'; });
var tree = document.getElementById('folderTree');
var html = '<div onclick="renderEntryList(\'\')" class="py-1 px-2 rounded hover:bg-navy-700 cursor-pointer text-sm">📁 All Items</div>';
folders.forEach(function(f) {
html += '<div onclick="renderEntryList(\'' + f.entry_id + '\')" class="py-1 px-2 rounded hover:bg-navy-700 cursor-pointer text-sm ml-2">📁 ' + escapeHtml(f.title) + '</div>';
});
tree.innerHTML = html;
}
// Entry detail
async function showEntry(id) {
try {
currentEntry = await api('GET', '/api/entries/' + id);
renderEntryDetail();
} catch (e) {
toast('Failed to load entry', 'error');
}
}
function renderEntryDetail() {
var e = currentEntry;
var content = document.getElementById('content');
var fieldsHtml = '';
if (e.data && e.data.fields) {
e.data.fields.forEach(function(f, i) {
fieldsHtml += '<div class="p-3 bg-navy-700 rounded">' +
'<div class="text-xs text-gray-400 mb-1">' + escapeHtml(f.label) + (f.l2 ? ' <span class="text-amber-400">🔒 L2</span>' : '') + '</div>' +
'<div class="flex items-center gap-2">';
if (f.l2) {
fieldsHtml += '<span class="text-amber-400 italic">🔒 Locked — Touch to unlock</span>';
} else if (f.kind === 'password') {
fieldsHtml += '<span class="password-masked" id="field-' + i + '">••••••••</span>' +
'<button onclick="togglePassword(' + i + ', \'' + escapeHtml(f.value).replace(/'/g, "\\'") + '\')" class="text-xs text-gray-400 hover:text-white">👁</button>';
} else {
fieldsHtml += '<span id="field-' + i + '">' + escapeHtml(f.value) + '</span>';
}
if (!f.l2) {
fieldsHtml += '<button onclick="copyField(\'' + escapeHtml(f.value).replace(/'/g, "\\'") + '\')" class="text-xs text-gray-400 hover:text-white">📋</button>';
}
fieldsHtml += '</div></div>';
});
}
var html = '<div class="max-w-2xl">' +
'<div class="flex items-center gap-4 mb-6">' +
'<button onclick="loadEntries()" class="text-gray-400 hover:text-white">← Back</button>' +
'<div class="text-3xl">' + getTypeIcon(e.type) + '</div>' +
'<div>' +
'<h2 class="text-xl font-bold">' + escapeHtml(e.title) + '</h2>' +
'<div class="text-sm text-gray-400">' + e.type + '</div>' +
'</div>' +
'</div>';
if (e.data && e.data.urls && e.data.urls.length) {
html += '<div class="mb-4"><div class="text-xs text-gray-400 mb-1">URLs</div>';
e.data.urls.forEach(function(u) {
html += '<a href="' + escapeHtml(u) + '" target="_blank" class="text-gold hover:underline block">' + escapeHtml(u) + '</a>';
});
html += '</div>';
}
html += '<div class="space-y-3 mb-6">' + fieldsHtml + '</div>';
if (e.data && e.data.notes) {
html += '<div class="p-3 bg-navy-700 rounded mb-6">' +
'<div class="text-xs text-gray-400 mb-1">Notes</div>' +
'<div class="whitespace-pre-wrap">' + escapeHtml(e.data.notes) + '</div>' +
'</div>';
}
html += '<div class="flex gap-3">' +
'<button onclick="editEntry()" class="px-4 py-2 bg-navy-700 border border-navy-600 rounded hover:bg-navy-600">Edit</button>' +
'<button onclick="deleteEntry(\'' + e.entry_id + '\')" class="px-4 py-2 bg-red-600/20 text-red-400 border border-red-600/30 rounded hover:bg-red-600/30">Delete</button>' +
'</div></div>';
content.innerHTML = html;
}
function togglePassword(idx, value) {
var el = document.getElementById('field-' + idx);
if (el.textContent === '••••••••') {
el.textContent = value;
} else {
el.textContent = '••••••••';
}
}
async function copyField(value) {
await navigator.clipboard.writeText(value);
toast('Copied!');
}
async function deleteEntry(id) {
if (!confirm('Delete this entry?')) return;
try {
await api('DELETE', '/api/entries/' + id);
toast('Entry deleted');
loadEntries();
} catch (e) {
toast('Delete failed', 'error');
}
}
// New Entry
var fieldCount = 0;
function showNewEntry() {
var modal = document.getElementById('modal');
var content = document.getElementById('modalContent');
content.innerHTML =
'<div class="p-6">' +
'<h3 class="text-lg font-bold mb-4">New Entry</h3>' +
'<form id="newEntryForm" class="space-y-4">' +
'<div>' +
'<label class="block text-sm text-gray-400 mb-1">Type</label>' +
'<select id="entryType" class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded">' +
'<option value="credential">Credential</option>' +
'<option value="card">Card</option>' +
'<option value="identity">Identity</option>' +
'<option value="note">Note</option>' +
'<option value="ssh_key">SSH Key</option>' +
'<option value="totp">TOTP</option>' +
'<option value="folder">Folder</option>' +
'</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm text-gray-400 mb-1">Title</label>' +
'<input type="text" id="entryTitle" class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded" required>' +
'</div>' +
'<div id="fieldsContainer">' +
'<label class="block text-sm text-gray-400 mb-1">Fields</label>' +
'<div id="fieldsList" class="space-y-2"></div>' +
'<button type="button" onclick="addField()" class="mt-2 text-sm text-gold hover:underline">+ Add Field</button>' +
'</div>' +
'<div>' +
'<label class="block text-sm text-gray-400 mb-1">URLs (one per line)</label>' +
'<textarea id="entryURLs" rows="2" class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded"></textarea>' +
'</div>' +
'<div>' +
'<label class="block text-sm text-gray-400 mb-1">Notes</label>' +
'<textarea id="entryNotes" rows="3" class="w-full px-3 py-2 bg-navy-700 border border-navy-600 rounded"></textarea>' +
'</div>' +
'<div class="flex justify-end gap-3">' +
'<button type="button" onclick="closeModal()" class="px-4 py-2 bg-navy-700 rounded">Cancel</button>' +
'<button type="submit" class="px-4 py-2 bg-gold text-navy-900 rounded font-medium">Save</button>' +
'</div>' +
'</form>' +
'</div>';
document.getElementById('entryType').addEventListener('change', updateDefaultFields);
updateDefaultFields();
document.getElementById('newEntryForm').addEventListener('submit', saveNewEntry);
modal.classList.remove('hidden');
}
function updateDefaultFields() {
var type = document.getElementById('entryType').value;
var container = document.getElementById('fieldsList');
container.innerHTML = '';
fieldCount = 0;
var defaults = {
credential: [['Username', 'text', false], ['Password', 'password', false]],
card: [['Cardholder', 'text', false], ['Number', 'password', true], ['CVV', 'password', true], ['Expiry', 'text', false]],
identity: [['First Name', 'text', false], ['Last Name', 'text', false], ['Email', 'text', false], ['Phone', 'text', false]],
ssh_key: [['Private Key', 'password', true]],
totp: [['Secret', 'totp', true]],
note: [],
folder: []
};
var fields = defaults[type] || [];
fields.forEach(function(f) { addField(f[0], f[1], f[2]); });
}
function addField(label, kind, l2) {
label = label || '';
kind = kind || 'text';
l2 = l2 || false;
var container = document.getElementById('fieldsList');
var idx = fieldCount++;
var div = document.createElement('div');
div.className = 'flex gap-2 items-start';
div.innerHTML =
'<input type="text" placeholder="Label" value="' + escapeHtml(label) + '" class="field-label flex-1 px-2 py-1 bg-navy-700 border border-navy-600 rounded text-sm">' +
'<input type="text" placeholder="Value" class="field-value flex-1 px-2 py-1 bg-navy-700 border border-navy-600 rounded text-sm">' +
'<select class="field-kind px-2 py-1 bg-navy-700 border border-navy-600 rounded text-sm">' +
'<option value="text"' + (kind==='text'?' selected':'') + '>Text</option>' +
'<option value="password"' + (kind==='password'?' selected':'') + '>Password</option>' +
'<option value="totp"' + (kind==='totp'?' selected':'') + '>TOTP</option>' +
'<option value="url"' + (kind==='url'?' selected':'') + '>URL</option>' +
'</select>' +
'<label class="flex items-center gap-1 text-sm">' +
'<input type="checkbox" class="field-l2"' + (l2?' checked':'') + '>' +
'<span class="text-amber-400">L2</span>' +
'</label>' +
'<button type="button" onclick="this.parentElement.remove()" class="text-red-400 hover:text-red-300">×</button>';
container.appendChild(div);
}
async function saveNewEntry(e) {
e.preventDefault();
var fieldDivs = document.querySelectorAll('#fieldsList > div');
var fields = [];
fieldDivs.forEach(function(div) {
var label = div.querySelector('.field-label').value;
if (label) {
fields.push({
label: label,
value: div.querySelector('.field-value').value,
kind: div.querySelector('.field-kind').value,
l2: div.querySelector('.field-l2').checked
});
}
});
var urlText = document.getElementById('entryURLs').value;
var urls = urlText.split('\n').map(function(u) { return u.trim(); }).filter(Boolean);
var entry = {
type: document.getElementById('entryType').value,
title: document.getElementById('entryTitle').value,
data: {
title: document.getElementById('entryTitle').value,
type: document.getElementById('entryType').value,
fields: fields,
urls: urls,
notes: document.getElementById('entryNotes').value
}
};
try {
await api('POST', '/api/entries', entry);
toast('Entry created!');
closeModal();
loadEntries();
} catch (err) {
toast('Failed to create entry', 'error');
}
}
function editEntry() {
showNewEntry();
setTimeout(function() {
var e = currentEntry;
document.getElementById('entryType').value = e.type;
document.getElementById('entryTitle').value = e.title;
if (e.data) {
if (e.data.urls) document.getElementById('entryURLs').value = e.data.urls.join('\n');
if (e.data.notes) document.getElementById('entryNotes').value = e.data.notes;
document.getElementById('fieldsList').innerHTML = '';
fieldCount = 0;
(e.data.fields || []).forEach(function(f) {
addField(f.label, f.kind, f.l2);
var lastField = document.querySelector('#fieldsList > div:last-child');
lastField.querySelector('.field-value').value = f.value;
});
}
var form = document.getElementById('newEntryForm');
form.onsubmit = async function(ev) {
ev.preventDefault();
var fieldDivs = document.querySelectorAll('#fieldsList > div');
var fields = [];
fieldDivs.forEach(function(div) {
var label = div.querySelector('.field-label').value;
if (label) {
fields.push({
label: label,
value: div.querySelector('.field-value').value,
kind: div.querySelector('.field-kind').value,
l2: div.querySelector('.field-l2').checked
});
}
});
var urlText = document.getElementById('entryURLs').value;
var urls = urlText.split('\n').map(function(u) { return u.trim(); }).filter(Boolean);
var updated = {
type: document.getElementById('entryType').value,
title: document.getElementById('entryTitle').value,
version: e.version,
data: {
title: document.getElementById('entryTitle').value,
type: document.getElementById('entryType').value,
fields: fields,
urls: urls,
notes: document.getElementById('entryNotes').value
}
};
try {
await api('PUT', '/api/entries/' + e.entry_id, updated);
toast('Entry updated!');
closeModal();
showEntry(e.entry_id);
} catch (err) {
toast('Failed to update entry', 'error');
}
};
}, 100);
}
// Import
var importedEntries = [];
function showImport() {
var modal = document.getElementById('modal');
var content = document.getElementById('modalContent');
content.innerHTML =
'<div class="p-6">' +
'<h3 class="text-lg font-bold mb-4">Import Entries</h3>' +
'<p class="text-gray-400 text-sm mb-4">Upload a password manager export (CSV, JSON, or any format). Our AI will parse it automatically.</p>' +
'<form id="importForm" enctype="multipart/form-data">' +
'<div class="border-2 border-dashed border-navy-600 rounded-lg p-8 text-center mb-4">' +
'<input type="file" id="importFile" class="hidden" accept=".csv,.json,.txt,.xml">' +
'<label for="importFile" class="cursor-pointer">' +
'<div class="text-4xl mb-2">📄</div>' +
'<div class="text-gray-400">Drop file here or click to browse</div>' +
'</label>' +
'</div>' +
'<div id="importPreview" class="hidden"></div>' +
'<div class="flex justify-end gap-3">' +
'<button type="button" onclick="closeModal()" class="px-4 py-2 bg-navy-700 rounded">Cancel</button>' +
'<button type="submit" class="px-4 py-2 bg-gold text-navy-900 rounded font-medium">Import</button>' +
'</div>' +
'</form>' +
'</div>';
document.getElementById('importFile').addEventListener('change', previewImport);
document.getElementById('importForm').addEventListener('submit', doImport);
modal.classList.remove('hidden');
}
async function previewImport(e) {
var file = e.target.files[0];
if (!file) return;
var formData = new FormData();
formData.append('file', file);
document.getElementById('importPreview').innerHTML = '<div class="text-gray-400">Analyzing file...</div>';
document.getElementById('importPreview').classList.remove('hidden');
try {
var res = await fetch('/api/import', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
});
var data = await res.json();
if (data.error) {
document.getElementById('importPreview').innerHTML = '<div class="text-red-400">' + data.error + '</div>';
return;
}
importedEntries = data.entries;
var html = '<div class="mb-4 text-sm text-gray-400">' + data.count + ' entries found</div>' +
'<div class="max-h-60 overflow-y-auto space-y-2">';
data.entries.forEach(function(e, i) {
html += '<div class="p-2 bg-navy-700 rounded text-sm flex items-center gap-2">' +
'<input type="checkbox" checked class="import-check" data-idx="' + i + '">' +
'<span>' + getTypeIcon(e.type) + '</span>' +
'<span>' + escapeHtml(e.title) + '</span>' +
(e.fields && e.fields.some(function(f) { return f.l2; }) ? '<span class="text-amber-400">🔒</span>' : '') +
'</div>';
});
html += '</div>';
document.getElementById('importPreview').innerHTML = html;
} catch (err) {
document.getElementById('importPreview').innerHTML = '<div class="text-red-400">Import failed: ' + err.message + '</div>';
}
}
async function doImport(e) {
e.preventDefault();
var checkboxes = document.querySelectorAll('.import-check:checked');
var selected = [];
checkboxes.forEach(function(cb) {
selected.push(importedEntries[parseInt(cb.dataset.idx)]);
});
if (selected.length === 0) {
toast('No entries selected', 'error');
return;
}
try {
var data = await api('POST', '/api/import/confirm', { entries: selected });
toast('Imported ' + data.imported + ' entries!');
closeModal();
loadEntries();
} catch (err) {
toast('Import failed', 'error');
}
}
// Audit Log
async function showAudit() {
var modal = document.getElementById('modal');
var content = document.getElementById('modalContent');
content.innerHTML = '<div class="p-6 text-gray-400">Loading...</div>';
modal.classList.remove('hidden');
try {
var events = await api('GET', '/api/audit?limit=50');
var html = '<div class="p-6">' +
'<h3 class="text-lg font-bold mb-4">Audit Log</h3>' +
'<div class="max-h-96 overflow-y-auto">' +
'<table class="w-full text-sm">' +
'<thead class="text-gray-400 text-left">' +
'<tr><th class="py-1">Time</th><th>Action</th><th>Entry</th><th>Actor</th></tr>' +
'</thead>' +
'<tbody>';
events.forEach(function(e) {
html += '<tr class="border-t border-navy-600">' +
'<td class="py-2">' + new Date(e.created_at).toLocaleString() + '</td>' +
'<td>' + e.action + '</td>' +
'<td>' + escapeHtml(e.title || '-') + '</td>' +
'<td>' + e.actor + '</td>' +
'</tr>';
});
html += '</tbody></table></div>' +
'<div class="mt-4 flex justify-end">' +
'<button onclick="closeModal()" class="px-4 py-2 bg-navy-700 rounded">Close</button>' +
'</div></div>';
content.innerHTML = html;
} catch (err) {
content.innerHTML = '<div class="p-6 text-red-400">Failed to load audit log</div>';
}
}
// Search
document.getElementById('searchBox').addEventListener('input', async function(e) {
var q = e.target.value.trim();
if (q.length < 2) {
renderEntryList();
return;
}
try {
var results = await api('GET', '/api/search?q=' + encodeURIComponent(q));
entries = results;
renderEntryList();
} catch (err) {
// ignore
}
});
// Utilities
function closeModal() {
document.getElementById('modal').classList.add('hidden');
}
function getTypeIcon(type) {
var icons = {
credential: '🔑',
card: '💳',
identity: '👤',
note: '📝',
ssh_key: '🔐',
totp: '🔢',
folder: '📁',
custom: '📦'
};
return icons[type] || '📄';
}
function hasL2Fields(entry) {
return entry.data && entry.data.fields && entry.data.fields.some(function(f) { return f.l2; });
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Init
if (token) {
loadEntries();
} else {
showSetup();
}
// Close modal on backdrop click
document.getElementById('modal').addEventListener('click', function(e) {
if (e.target.id === 'modal') closeModal();
});
</script>
</body>
</html>

106
extension/background.js Normal file
View File

@ -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 });
}
}
});

124
extension/content.js Normal file
View File

@ -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 });

BIN
extension/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

BIN
extension/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

BIN
extension/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

28
extension/manifest.json Normal file
View File

@ -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": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}],
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}

139
extension/popup.html Normal file
View File

@ -0,0 +1,139 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 320px;
min-height: 200px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111827;
color: #e5e7eb;
}
.header {
padding: 12px 16px;
background: #0a0f1a;
border-bottom: 1px solid #374151;
display: flex;
align-items: center;
gap: 8px;
}
.header h1 {
font-size: 14px;
font-weight: 600;
color: #c9a84c;
}
.content { padding: 12px; }
.url {
font-size: 11px;
color: #9ca3af;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry {
padding: 10px;
background: #1f2937;
border: 1px solid #374151;
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.entry:hover { border-color: #c9a84c; }
.entry-title { font-weight: 500; }
.entry-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.entry-l2 { color: #fbbf24; }
.empty {
text-align: center;
color: #6b7280;
padding: 24px;
}
.settings-link {
font-size: 11px;
color: #c9a84c;
text-decoration: none;
display: block;
text-align: center;
padding: 8px;
border-top: 1px solid #374151;
}
.settings-link:hover { text-decoration: underline; }
/* Settings view */
.settings { display: none; padding: 12px; }
.settings.active { display: block; }
.settings label {
display: block;
font-size: 12px;
color: #9ca3af;
margin-bottom: 4px;
}
.settings input {
width: 100%;
padding: 8px;
margin-bottom: 12px;
background: #1f2937;
border: 1px solid #374151;
border-radius: 4px;
color: #e5e7eb;
font-size: 12px;
}
.settings input:focus {
outline: none;
border-color: #c9a84c;
}
.btn {
width: 100%;
padding: 10px;
background: #c9a84c;
color: #0a0f1a;
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
}
.btn:hover { background: #d4af5a; }
.btn-secondary {
background: #374151;
color: #e5e7eb;
}
.btn-secondary:hover { background: #4b5563; }
.status {
font-size: 11px;
text-align: center;
padding: 8px;
border-radius: 4px;
margin-bottom: 12px;
}
.status.success { background: #064e3b; color: #34d399; }
.status.error { background: #7f1d1d; color: #fca5a5; }
</style>
</head>
<body>
<div class="header">
<span>🔐</span>
<h1>ClawVault</h1>
</div>
<div id="matchView" class="content">
<div class="url" id="currentUrl"></div>
<div id="matches"></div>
<a href="#" class="settings-link" id="settingsLink">⚙️ Settings</a>
</div>
<div id="settingsView" class="settings">
<div id="settingsStatus"></div>
<label>Vault URL</label>
<input type="text" id="vaultUrl" placeholder="http://localhost:8765">
<label>API Token</label>
<input type="password" id="apiToken" placeholder="Your API token">
<button class="btn" id="saveSettings">Save Settings</button>
<button class="btn btn-secondary" id="backToMatches" style="margin-top: 8px;">← Back</button>
</div>
<script src="popup.js"></script>
</body>
</html>

174
extension/popup.js Normal file
View File

@ -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 = '<div class="empty">Loading...</div>';
chrome.runtime.sendMessage({ action: 'getMatches', url }, (response) => {
if (chrome.runtime.lastError || !response || !response.success) {
matchesDiv.innerHTML = '<div class="empty">Not connected to vault. Check settings.</div>';
return;
}
currentMatches = response.matches;
if (!currentMatches || currentMatches.length === 0) {
matchesDiv.innerHTML = '<div class="empty">No matching credentials</div>';
return;
}
let html = '';
currentMatches.forEach((entry, idx) => {
const hasL2 = entry.data && entry.data.fields && entry.data.fields.some(f => f.l2);
html += `<div class="entry" data-idx="${idx}">
<div class="entry-title">${escapeHtml(entry.title)}</div>
<div class="entry-meta">
${entry.type}
${hasL2 ? '<span class="entry-l2">🔒 L2</span>' : ''}
</div>
</div>`;
});
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Initialize
loadMatches();

14
go.mod Normal file
View File

@ -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

21
go.sum Normal file
View File

@ -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=

59
lib/config.go Normal file
View File

@ -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
}

136
lib/crypto.go Normal file
View File

@ -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)
}

469
lib/dbcore.go Normal file
View File

@ -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
}

121
lib/types.go Normal file
View File

@ -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"
)