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:
commit
0ff6db74cb
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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.*
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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 });
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 B |
Binary file not shown.
|
After Width: | Height: | Size: 87 B |
Binary file not shown.
|
After Width: | Height: | Size: 100 B |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
loadMatches();
|
||||
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
Loading…
Reference in New Issue