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