diff --git a/clavis/clavis-vault/CLAUDE.md b/clavis/clavis-vault/CLAUDE.md index 7a49a21..6f58b78 100644 --- a/clavis/clavis-vault/CLAUDE.md +++ b/clavis/clavis-vault/CLAUDE.md @@ -29,3 +29,176 @@ Exploratory/throwaway work has its place — but it stays in research. Nothing Q Call it out. Do not work around it. Bad foundations are not your fault — but silently building on them is. Surface the problem, we work on it together. The bar is high. The support is real. + +--- + +## Edition System (Community vs Commercial) + +Clavitor Vault has two editions with build-time separation: + +### Community Edition (Default) +```bash +go build -o clavitor ./cmd/clavitor/ +``` +- No telemetry by default (privacy-first) +- Local logging only +- Self-hosted +- AGPL license + +### Commercial Edition +```bash +go build -tags commercial -o clavitor ./cmd/clavitor/ +``` +- Centralized telemetry to clavitor.ai +- Operator alerts POST to `/v1/alerts` +- Multi-POP management +- Commercial license + +### Using the Edition Package + +```go +import "github.com/johanj/clavitor/edition" + +// Send operator alerts (works in both editions) +edition.Current.AlertOperator(ctx, "auth_error", "message", details) + +// Check edition +currentEdition := edition.Current.Name() // "community" or "commercial" +``` + +See `edition/CLAUDE.md` for full documentation. + +--- + +## Clavitor Vault v2 — Current State & Testing + +### What we built this session + +#### 1. Domain classification for import scopes +- Import page (`cmd/clavitor/web/import.html`) parses 14+ password manager formats client-side +- Unique domains are extracted (eTLD+1) and sent to `https://clavitor.ai/classify` +- The classify endpoint uses Claude Haiku on OpenRouter to categorize domains into 13 scopes: finance, social, shopping, work, dev, email, media, health, travel, home, education, government +- Results are stored permanently in SQLite on clavitor.ai (`domain_scopes` table) — NOT a cache, a lookup table that benefits all users +- Domains with no URL get scope "unclassified" (not "misc"). "misc" = LLM tried and failed +- Domains are sent in chunks of 200 to stay within token limits +- Classification is opt-in: user sees consent dialog with Yes/Skip/Cancel + +#### 2. Import flow UX +- Drop file → parse → hide file step → consent dialog (Yes/Skip/Cancel) +- Cancel returns to file step +- After classification: entry list with scope pills as clickable filters, scope group headers with checkboxes +- Import + Cancel buttons appear only after classification +- Wider layout (960px), one-line items: title + username, no URL clutter +- Black entry icons (LGN/CARD/NOTE) with white text — on brand +- Global black checkboxes (`accent-color: var(--text)`) +- Unified CSS classes: `.item-row`, `.item-icon`, `.item-list` (replacing import-specific classes) + +#### 3. Security hardening (IN PROGRESS — needs testing) +- **List endpoint stripped**: GET /api/entries now always returns metadata only (title, type, scopes, entry_id). No data blobs, no ?meta=1 toggle. Full entry data only via GET /api/entries/{id} with scope enforcement. +- **Agent system type guard**: Agents cannot create/update entries with type=agent or type=scope. Enforced on CreateEntry, CreateEntryBatch, UpsertEntry, UpdateEntry. +- **L3 field protection**: Agents cannot overwrite L3 fields. If existing field is tier 3, the agent's update preserves the original value silently. +- **Per-agent IP whitelist**: Stored in agent entry (L1-encrypted). Empty on creation → filled with IP from first contact → enforced on every subsequent request. Supports CIDRs (10.0.0.0/16), exact IPs, and FQDNs (home.smith.family), comma-separated. +- **Per-agent rate limiting**: Configurable requests/minute per agent ID (not per IP). Stored in agent entry. +- **Admin operations require PRF tap**: Agent CRUD and scope updates require a fresh WebAuthn assertion. Flow: POST /auth/admin/begin → PRF tap → POST /auth/admin/complete → one-time admin token in X-Admin-Token header → pass to admin endpoint. Token is single-use, 5-minute expiry. + +### What is semi-done / needs testing + +The security hardening code compiles and the vault runs, but none of it has been tested with actual agent tokens or WebAuthn assertions yet. Specifically: + +1. **IP whitelist first-contact fill**: ✅ Fixed - DB errors now return 500 +2. **IP whitelist enforcement**: Does CIDR matching work? FQDN resolution? Comma-separated lists? FQDN now has 5-min cache +3. **Per-agent rate limiter**: Does it correctly track per agent ID and reset per minute? +4. **Admin auth flow**: Does the challenge-response work end-to-end? Does the admin token get consumed correctly (single-use)? +5. **System type guards**: ✅ Fixed - Agents blocked entirely from batch import; returns 403 on forbidden types +6. **L3 field preservation**: ✅ Fixed - Agents cannot overwrite L3 fields in batch or upsert +7. **List endpoint**: Verify no data blobs leak. Check browser console: entries[0] should have no data or fields property. + +### Known Issues (Accepted) + +**IP Whitelist Race Condition**: There is a theoretical race on first-contact IP recording if two parallel requests from different IPs arrive simultaneously. This was reviewed and accepted because: +- Requires a stolen agent token (already a compromise) +- Requires racing first contact from two different IPs +- The "loser" simply won't be auto-whitelisted +- Cannot be reproduced in testing; practically impossible to trigger +- Fix would require plaintext column + atomic update (not worth complexity) + +See comment in `api/middleware.go` for full rationale. + +**Admin Token Consumed Early**: The admin token is consumed immediately upon validation in `requireAdmin()`. If the subsequent operation fails (DB error, validation error, etc.), the token is gone but the operation didn't complete. The user must perform a fresh PRF tap to retry. + +This was reviewed and accepted because: +- 5-10 minute token lifetime makes re-auth acceptable +- It's a UX inconvenience, not a security vulnerability +- Deferring consumption until operation success would require transaction-like complexity +- Rare edge case: requires admin operation to fail after token validation + +### How testing works + +No automated test suite for this session's work. Testing is manual via the browser: + +1. Vault runs locally on forge (this machine) at port 8443, accessed via https://dev.clavitor.ai/app/ +2. Caddy on 192.168.0.2 reverse-proxies dev.clavitor.ai → forge:8443 +3. Import testing: Drop a Proton Pass ZIP export (or any of the 14 supported formats) on the import page. Check scope pills, counts, classifications. +4. Classification testing: Watch server logs on clavitor.ai: `ssh root@ "journalctl -u clavitor-web --no-pager -n 30"`. Check domain_scopes table: `sqlite3 /opt/clavitor-web/clavitor.db 'SELECT COUNT(*) FROM domain_scopes'` +5. Screen capture: `/capture` skill takes a live screenshot from Johan's Mac (display 3). `/screenshot` fetches the latest manual screenshot. +6. Version verification: Check topbar shows correct version (currently v2.0.44 in cmd/clavitor/web/topbar.js). If version doesn't update after rebuild, the old binary is still running — kill it properly (beware tarpit holding process alive for 30s). +7. DB location: Vault data is in `/home/johan/dev/clavitor/clavis/clavis-vault/data/`. Delete clavitor-* files there to start fresh (will require passkey re-registration). + +### Key files + +| File | What | +|------|------| +| api/handlers.go | All HTTP handlers, security guards, admin auth | +| api/middleware.go | L1 auth, CVT token parsing, IP whitelist, agent rate limit | +| lib/types.go | AgentData, VaultData, AgentCanAccess, AgentIPAllowed | +| lib/dbcore.go | DB ops, AgentLookup, AgentUpdateAllowedIPs | +| cmd/clavitor/web/import.html | Import page | +| cmd/clavitor/web/importers.js | 14 parsers, classifyDomains, applyScopes, FIELD_SPEC | +| cmd/clavitor/web/topbar.js | Version number, nav, idle timer | +| cmd/clavitor/web/clavitor-app.css | All styles, item-row/item-icon system | +| clavitor.ai/main.go | Portal + /classify endpoint (Haiku on OpenRouter) | + +### Deploy Clavitor Vault (dev) + +Working directory: `/home/johan/dev/clavitor/clavis/clavis-vault` + +```bash +# Build +go build -o clavitor-linux-amd64 ./cmd/clavitor/ + +# Kill existing +kill -9 $(pgrep -f 'clavitor-linux-amd64' | head -1) 2>/dev/null +sleep 3 + +# Start (data dir must be persistent, NOT /tmp) +DATA_DIR=/home/johan/dev/clavitor/clavis/clavis-vault/data \ + nohup ./clavitor-linux-amd64 -port 8443 > vault.log 2>&1 & +``` + +Caddy on 192.168.0.2 reverse-proxies dev.clavitor.ai → forge:8443 (self-signed, so tls_insecure_skip_verify). + +Web files are embedded at compile time (go:embed). CSS/JS/HTML changes require rebuild. + +Bump version in `cmd/clavitor/web/topbar.js` (search for v2.0.) to verify new build is live. + +### Deploy clavitor.ai (prod) + +Working directory: `/home/johan/dev/clavitor/clavitor.ai` + +```bash +make deploy-prod +``` + +This cross-compiles, SCPs to Zürich, enters maintenance mode, restarts systemd, exits maintenance. One command. + +SSH: root@clavitor.ai — port 22 blocked on public IP, use Tailscale. Never use johan@. Avoid rapid SSH attempts (fail2ban will lock you out — it already happened once this session). + +Env vars are in `/opt/clavitor-web/.env` and `/etc/systemd/system/clavitor-web.service`. After changing .env, run `systemctl daemon-reload && systemctl restart clavitor-web` on the server. + +**NEVER deploy the database. Only the binary gets uploaded. The SQLite DB on prod is the source of truth.** + +Verify: `ssh root@ "systemctl status clavitor-web"` + +### IMPORTANT + +**NEVER deploy to prod without Johan's explicit approval. This caused a SEV-1 on 2026-03-29.** diff --git a/clavis/clavis-vault/GIT_WORKFLOW.md b/clavis/clavis-vault/GIT_WORKFLOW.md new file mode 100644 index 0000000..db7707f --- /dev/null +++ b/clavis/clavis-vault/GIT_WORKFLOW.md @@ -0,0 +1,117 @@ +# Git Workflow — Zurich Server Only + +## Critical Policy + +**NEVER push to GitHub.** The repository at `git@zurich.inou.com:clavitor.git` is the only remote. + +## Why Zurich-Only? + +1. **Commercial code protection** — The `edition/commercial.go` file contains proprietary logic that must never leak +2. **Pre-release privacy** — Community edition is not yet ready for public GitHub release +3. **Unified source of truth** — All development happens on Zurich, deployment flows from there + +## Repository Structure + +``` +zurich.inou.com:clavitor.git +├── clavitor/ # This vault codebase +│ ├── cmd/clavitor/ # Main application +│ ├── api/ # HTTP handlers +│ ├── lib/ # Core libraries +│ ├── edition/ # ⬅️ COMMERCIAL/Community split +│ │ ├── edition.go # Interface (shared) +│ │ ├── community.go # Community Edition (AGPL) +│ │ └── commercial.go # ⬅️ COMMERCIAL ONLY (proprietary) +│ └── ... +├── clavitor.ai/ # Hosted portal (commercial) +└── clavitor.com/ # Marketing site +``` + +## Build Tags Matter + +| Build Command | Edition | License | +|--------------|---------|---------| +| `go build ./cmd/clavitor/` | Community | AGPL | +| `go build -tags commercial ./cmd/clavitor/` | Commercial | Proprietary | + +**Key point:** Both editions are in the same Git repo. The `-tags commercial` build flag is what enables commercial features. + +## What Gets Committed + +**DO commit:** +- Source code (*.go, *.js, *.css, *.html) +- Documentation (*.md) +- Configuration (go.mod, Makefile) +- Test files (*_test.go) + +**DO NOT commit:** +- Binaries (clavitor-linux-amd64, clavitor-web) +- Database files (*.db, *.db-shm, *.db-wal) +- Log files (vault.log) +- OS files (.DS_Store, ._.DS_Store) +- Generated files (build/, *.o) + +## Daily Workflow + +```bash +# 1. Check you're on Zurich remote +git remote -v +# Should show: origin git@zurich.inou.com:clavitor.git + +# 2. Pull latest +git pull origin main + +# 3. Work on code... + +# 4. Stage changes (careful - review what you're staging) +git status +git add + +# 5. Commit with descriptive message +git commit -m "feature: add FQDN caching for agent IP whitelist" + +# 6. Push to Zurich only +git push origin main +``` + +## Emergency: GitHub Leak Prevention + +If you accidentally add GitHub as a remote or push there: + +```bash +# 1. Remove GitHub remote immediately +git remote remove github + +# 2. Check what was pushed +git log github/main --not zurich/main + +# 3. If commercial code leaked, contact Johan immediately +# We may need to rotate tokens or change implementation details +``` + +## Future: GitHub Release (Community Only) + +When ready for public release: + +1. Create `community-release` branch on Zurich +2. Verify `edition/commercial.go` is properly tagged with `//go:build commercial` +3. Export to GitHub as NEW repository (not this one) +4. Only community edition builds from that repo +5. Commercial stays on Zurich forever + +## SSH Access to Zurich + +```bash +ssh git@zurich.inou.com +# Or via Tailscale (if blocked on public IP) +ssh git@100.x.x.x # Tailscale IP +``` + +**Never:** +- Use `git@github.com:johanj/clavitor.git` as remote +- Push to any `github.com` URL +- Include commercial code in GitHub issues/PRs + +## Questions? + +Ask Johan. This is a business-critical security boundary. diff --git a/clavis/clavis-vault/SPEC-dual-build.md b/clavis/clavis-vault/SPEC-dual-build.md new file mode 100644 index 0000000..14629e2 --- /dev/null +++ b/clavis/clavis-vault/SPEC-dual-build.md @@ -0,0 +1,256 @@ +# Spec: Community / Enterprise Dual-Build Model + +## Context + +Clavitor ships two editions from a single codebase: + +- **Community Edition** (ELv2, open source): single vault, full encryption, CLI, browser extension, manual scope assignment +- **Enterprise Edition** (closed source, hosted): everything in Community + management plane APIs, auto-scope, SCIM webhook receiver, cross-vault audit feed, telemetry hooks + +The split is compile-time via Go build tags. No runtime feature flags. No license key checks. Enterprise code simply doesn't exist in the community binary. + +## Architecture + +### Build tags + +All enterprise-only code lives behind `//go:build enterprise`. + +``` +Community build: go build -o clavitor . +Enterprise build: go build -tags enterprise -o clavitor . +``` + +### File structure + +``` +api/ + handlers.go # shared: GET /entry, GET /totp, GET /entries, GET /search + handlers_admin.go # shared: POST/PUT/DELETE /agent, /entry (WebAuthn-gated) + handlers_enterprise.go # enterprise only (build tag): management plane APIs + routes.go # shared routes + routes_enterprise.go # enterprise only (build tag): registers enterprise routes + +scopes/ + scopes.go # shared: scope matching, entry access checks + autoscope.go # enterprise only (build tag): LLM-based scope classification + +scim/ + scim.go # enterprise only (build tag): SCIM webhook receiver + +audit/ + audit.go # shared: per-vault audit logging + audit_feed.go # enterprise only (build tag): cross-vault audit export feed + +telemetry/ + telemetry.go # enterprise only (build tag): node telemetry reporting +``` + +### Enterprise route registration + +Use an init pattern so enterprise routes self-register when the build tag is present: + +```go +// routes_enterprise.go +//go:build enterprise + +package api + +func init() { + enterpriseRoutes = append(enterpriseRoutes, + Route{"POST", "/api/mgmt/agents/bulk", handleBulkAgentCreate}, + Route{"DELETE", "/api/mgmt/agents/bulk", handleBulkAgentRevoke}, + Route{"GET", "/api/mgmt/audit", handleCrossVaultAudit}, + Route{"POST", "/api/scim/v2/Users", handleSCIMUserCreate}, + Route{"DELETE", "/api/scim/v2/Users/{id}", handleSCIMUserDelete}, + Route{"POST", "/api/autoscope", handleAutoScope}, + ) +} +``` + +```go +// routes.go (shared) +package api + +var enterpriseRoutes []Route // empty in community build + +func RegisterRoutes(mux *http.ServeMux) { + // shared routes + mux.HandleFunc("GET /api/entries", handleListEntries) + mux.HandleFunc("GET /api/entries/{id}", handleGetEntry) + mux.HandleFunc("GET /api/ext/totp/{id}", handleGetTOTP) + // ... all shared routes + + // enterprise routes (empty slice in community build) + for _, r := range enterpriseRoutes { + mux.HandleFunc(r.Method+" "+r.Path, r.Handler) + } +} +``` + +### Scope implementation (shared) + +Implement the scope model in the shared codebase. This is needed by both editions. + +Schema (add to existing DB): + +```sql +-- agents table +CREATE TABLE IF NOT EXISTS agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + scopes TEXT NOT NULL DEFAULT '', + all_access INTEGER NOT NULL DEFAULT 0, + admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +-- add scopes column to entries table +ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; +``` + +Agent ID = scope. Auto-increment ordinal, displayed as 4-char zero-padded hex (`printf("%04x", id)`). + +Token format: `cvt_` prefix + 32 bytes random (base62). Stored as sha256 hash. The `cvt_` prefix allows secret scanning tools (GitHub, GitLab) to detect leaked tokens. + +Matching logic: +- Entry scopes empty (`""`) = owner only (`all_access` required) +- Otherwise: set intersection of agent.scopes and entry.scopes +- `all_access=1` bypasses scope check + +Flags: + +| all_access | admin | Role | +|-----------|-------|------| +| 0 | 0 | Scoped read-only (agents, family members, MSP techs) | +| 1 | 0 | Read everything, change nothing (MSP tech full access) | +| 0 | 1 | Scoped read + admin ops with WebAuthn (unusual but valid) | +| 1 | 1 | Vault owner | + +### Admin operations (shared, WebAuthn-gated) + +All admin endpoints require: valid bearer token + `admin=1` + valid WebAuthn assertion. + +``` +POST /api/agents create agent/token +GET /api/agents list agents (admin read, no WebAuthn needed) +PUT /api/agents/{id} modify agent +DELETE /api/agents/{id} revoke agent +POST /api/entries create entry +PUT /api/entries/{id} modify entry (including scopes) +PUT /api/entries/{id}/scopes modify scopes only +DELETE /api/entries/{id} delete entry +POST /api/webauthn/challenge get WebAuthn challenge (60s TTL) +``` + +WebAuthn tables: + +```sql +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id TEXT PRIMARY KEY, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + challenge_id TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +``` + +### WebAuthn flow + +``` +1. Client: POST /api/webauthn/challenge → vault returns random challenge (60s TTL) +2. User taps hardware key (fingerprint, face, YubiKey) +3. Browser signs challenge with hardware token's private key +4. Client sends: bearer token + X-WebAuthn-Assertion header + X-WebAuthn-Challenge header + admin request body +5. Vault verifies: + a. Bearer token valid? (agent row exists) + b. admin = 1? + c. WebAuthn signature valid against stored public key? + d. Challenge matches and not expired? + → All yes? Execute admin operation. + → Any no? 403. +6. Challenge deleted (single use). Expired challenges cleaned up periodically. +``` + +### Auto-scope (enterprise only) + +```go +//go:build enterprise + +// POST /api/autoscope +// Takes entry fields, returns suggested scope assignments +// Uses LLM to classify fields as Credential vs Identity +// and suggest which agent scopes should have access +``` + +### Makefile + +```makefile +build: + go build -o clavitor . + +build-enterprise: + go build -tags enterprise -o clavitor . + +build-prod: + GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -tags enterprise -o clavitor-linux-amd64 . +``` + +## Constraints + +- The community binary must work standalone with zero enterprise code compiled in +- No runtime checks for "is this enterprise?" — the code paths don't exist +- No feature flags, no license keys, no env vars +- Enterprise files must all have `//go:build enterprise` as the first line +- The agents table and scopes column are shared (both editions need scoping) +- WebAuthn is shared (both editions need hardware-gated admin) +- Token format `cvt_` prefix is shared +- Cannot delete the last admin agent (prevent lockout) +- Cannot remove admin flag from self if no other admin exists + +### Tier enforcement + +The vault itself does not enforce tier limits. The hosted management layer (outside the vault) enforces: +- Token count: `SELECT COUNT(*) FROM agents` checked before `POST /api/agents` +- Device count: `SELECT COUNT(*) FROM webauthn_credentials` checked before WebAuthn registration +- Vault count: checked at the account level, not within the vault + +Self-hosted (Community Edition) vaults have no limits on tokens or devices within a single vault. + +## Migration + +### For existing vaults (no scopes yet) + +1. Add `scopes` column to entries: `ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT '';` +2. Create agents table. +3. Existing auth tokens become agent rows: create agent row for each existing token with `all_access=1, admin=1` (vault owner). +4. All existing entries have `scopes = ""` (owner-only) by default — secure default, nothing changes until user explicitly creates agents and assigns scopes. + +### Vault owner bootstrap + +On first vault setup or migration: +1. User registers WebAuthn credential (hardware key enrollment). +2. System creates first agent row: `id=1, name="Owner", all_access=1, admin=1`. +3. User receives their bearer token (shown once). +4. All entries default to `scopes=""` (owner only). +5. User creates additional agents and assigns scopes via admin operations. + +## Deliverables + +1. Implement the agents table, token auth, and scope matching in shared code +2. Implement WebAuthn challenge/verify flow for admin operations +3. Implement all shared API endpoints (read + admin) +4. Create enterprise-only stubs (handlers_enterprise.go, routes_enterprise.go, autoscope.go, scim.go, audit_feed.go) with build tags and basic structure +5. Update Makefile with both build targets +6. Migration path for existing vaults (add scopes column, create initial owner agent) + +## Reference + +- Full scope spec: /home/johan/dev/clavitor/clavis/clavis-vault/SPEC-scopes.md +- Pricing architecture: /home/johan/dev/clavitor/marketing/pricing-architecture.md diff --git a/clavis/clavis-vault/api/handlers.go b/clavis/clavis-vault/api/handlers.go index 8eced55..62befd7 100644 --- a/clavis/clavis-vault/api/handlers.go +++ b/clavis/clavis-vault/api/handlers.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/base32" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "log" @@ -16,6 +17,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/johanj/clavitor/edition" "github.com/johanj/clavitor/lib" "github.com/pquerna/otp/totp" ) @@ -76,8 +78,8 @@ func (h *Handlers) cleanChallenges() { // --- Context helpers --- -func (h *Handlers) db(r *http.Request) *lib.DB { return DBFromContext(r.Context()) } -func (h *Handlers) vk(r *http.Request) []byte { return VaultKeyFromContext(r.Context()) } +func (h *Handlers) db(r *http.Request) *lib.DB { return DBFromContext(r.Context()) } +func (h *Handlers) vk(r *http.Request) []byte { return VaultKeyFromContext(r.Context()) } func (h *Handlers) agent(r *http.Request) *lib.AgentData { return AgentFromContext(r.Context()) } // l0 returns L0 (first 4 bytes of vault key before normalization). @@ -98,6 +100,79 @@ func (h *Handlers) requireOwner(w http.ResponseWriter, r *http.Request) bool { return false } +// requireAdmin rejects agents AND requires a fresh WebAuthn challenge proof. +// The client must first call /api/auth/admin/begin, do a PRF tap, call +// /api/auth/admin/complete, then pass the resulting admin token in X-Admin-Token. +// +// SECURITY NOTE: The admin token is consumed immediately upon validation. +// If the subsequent operation fails (DB error, validation error, etc.), the token +// is gone but the operation didn't complete. The user must perform a fresh PRF tap +// to retry. +// +// This was reviewed and accepted because: +// - 5-10 minute token lifetime makes re-auth acceptable +// - It's a UX inconvenience, not a security vulnerability +// - Deferring consumption until operation success would require transaction-like complexity +// - Rare edge case: requires admin operation to fail after token validation +func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if h.requireOwner(w, r) { + return true + } + token := r.Header.Get("X-Admin-Token") + if token == "" { + ErrorResponse(w, http.StatusForbidden, "admin_required", "Admin operation requires PRF authentication") + return true + } + tokenBytes, err := hex.DecodeString(token) + if err != nil { + ErrorResponse(w, http.StatusForbidden, "invalid_token", "Invalid admin token") + return true + } + // Token is consumed immediately. See SECURITY NOTE above. + if err := h.consumeChallenge(tokenBytes, "admin"); err != nil { + ErrorResponse(w, http.StatusForbidden, "expired_token", "Admin token expired or already used") + return true + } + return false +} + +// rejectAgentSystemWrite blocks agents from creating/updating agent or scope entries. +func rejectAgentSystemWrite(w http.ResponseWriter, r *http.Request, entryType string) bool { + if !IsAgentRequest(r) { + return false + } + if entryType == lib.TypeAgent || entryType == lib.TypeScope { + ErrorResponse(w, http.StatusForbidden, "system_type", "Agents cannot modify agent or scope records") + return true + } + return false +} + +// rejectAgentL3Overwrite blocks agents from overwriting L3 fields with lower-tier data. +// If an existing field is tier 3, the agent's update must keep the same value. +func rejectAgentL3Overwrite(w http.ResponseWriter, existing, incoming *lib.VaultData) bool { + if existing == nil || incoming == nil { + return false + } + existingL3 := make(map[string]string) + for _, f := range existing.Fields { + if f.Tier >= 3 { + existingL3[f.Label] = f.Value + } + } + if len(existingL3) == 0 { + return false + } + for i, f := range incoming.Fields { + if val, isL3 := existingL3[f.Label]; isL3 { + // Preserve the L3 value — agent cannot change it + incoming.Fields[i].Value = val + incoming.Fields[i].Tier = 3 + } + } + return false +} + // filterByScope removes entries the agent cannot access. func filterByScope(agent *lib.AgentData, entries []lib.Entry) []lib.Entry { if agent == nil { @@ -272,6 +347,10 @@ func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request) } func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) { + if h.db(r) == nil { + ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists") + return + } creds, err := lib.GetWebAuthnCredentials(h.db(r)) if err != nil || len(creds) == 0 { ErrorResponse(w, http.StatusNotFound, "no_credentials", "No credentials registered") @@ -303,6 +382,10 @@ func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) { + if h.db(r) == nil { + ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists") + return + } var req struct { Challenge []byte `json:"challenge"` CredentialID []byte `json:"credential_id"` @@ -326,40 +409,93 @@ func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) { JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"}) } +// AdminAuthBegin starts a WebAuthn assertion for admin operations (PRF tap required). +func (h *Handlers) AdminAuthBegin(w http.ResponseWriter, r *http.Request) { + if h.requireOwner(w, r) { + return + } + if h.db(r) == nil { + ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists") + return + } + creds, err := lib.GetWebAuthnCredentials(h.db(r)) + if err != nil || len(creds) == 0 { + ErrorResponse(w, http.StatusNotFound, "no_credentials", "No credentials registered") + return + } + challenge := make([]byte, 32) + rand.Read(challenge) + h.storeChallenge(challenge, "admin-begin") + + var allowCreds []map[string]any + var prfSalt []byte + for _, c := range creds { + allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredentialID}) + if len(c.PRFSalt) > 0 { + prfSalt = c.PRFSalt + } + } + prfExt := map[string]any{} + if len(prfSalt) > 0 { + prfExt["eval"] = map[string]any{"first": prfSalt} + } + + JSONResponse(w, http.StatusOK, map[string]any{ + "publicKey": map[string]any{ + "challenge": challenge, "rpId": rpID(r), "allowCredentials": allowCreds, + "userVerification": "required", "extensions": map[string]any{"prf": prfExt}, + }, + }) +} + +// AdminAuthComplete verifies the WebAuthn assertion and returns a one-time admin token. +func (h *Handlers) AdminAuthComplete(w http.ResponseWriter, r *http.Request) { + if h.requireOwner(w, r) { + return + } + if h.db(r) == nil { + ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists") + return + } + var req struct { + Challenge []byte `json:"challenge"` + CredentialID []byte `json:"credential_id"` + SignCount int `json:"sign_count"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") + return + } + if err := h.consumeChallenge(req.Challenge, "admin-begin"); err != nil { + ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed") + return + } + cred, err := lib.GetWebAuthnCredentialByRawID(h.db(r), req.CredentialID) + if err != nil { + ErrorResponse(w, http.StatusUnauthorized, "unknown_credential", "Credential not recognized") + return + } + lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount) + + // Issue one-time admin token (valid 5 minutes, single use) + adminToken := make([]byte, 32) + rand.Read(adminToken) + h.storeChallenge(adminToken, "admin") + + lib.AuditLog(h.db(r), &lib.AuditEvent{Action: "admin_auth", Actor: lib.ActorWeb, IPAddr: realIP(r)}) + JSONResponse(w, http.StatusOK, map[string]string{"admin_token": hex.EncodeToString(adminToken)}) +} + // --------------------------------------------------------------------------- // Entry CRUD (scope-checked) // --------------------------------------------------------------------------- func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) - actor := ActorFromContext(r.Context()) - if r.URL.Query().Get("meta") == "1" { - entries, err := lib.EntryListMeta(h.db(r)) - if err != nil { - ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") - return - } - if entries == nil { - entries = []lib.Entry{} - } - entries = filterOutSystemTypes(entries) - entries = filterByScope(agent, entries) - JSONResponse(w, http.StatusOK, entries) - return - } - - var parent *int64 - if pidStr := r.URL.Query().Get("parent_id"); pidStr != "" { - pid, err := lib.HexToID(pidStr) - if err != nil { - ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid parent_id") - return - } - parent = &pid - } - - entries, err := lib.EntryList(h.db(r), h.vk(r), parent) + // List endpoint returns metadata only — never decrypted data. + // Full entry data is only available via GET /entries/{id} with scope checks. + entries, err := lib.EntryListMeta(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") return @@ -369,14 +505,6 @@ func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { } entries = filterOutSystemTypes(entries) entries = filterByScope(agent, entries) - - if actor == lib.ActorAgent { - for i := range entries { - if entries[i].VaultData != nil { - stripL2Fields(entries[i].VaultData) - } - } - } JSONResponse(w, http.StatusOK, entries) } @@ -416,6 +544,7 @@ func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { + agent := h.agent(r) actor := ActorFromContext(r.Context()) var req struct { Type string `json:"type"` @@ -428,6 +557,14 @@ func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } + if rejectAgentSystemWrite(w, r, req.Type) { + return + } + // Security: Agents can only create entries with scopes they have access to + if agent != nil && !lib.AgentCanAccess(agent, req.Scopes) { + ErrorResponse(w, http.StatusForbidden, "forbidden_scope", "Cannot create entry with scopes outside your access") + return + } if req.Title == "" { ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required") return @@ -452,6 +589,12 @@ func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) { func (h *Handlers) CreateEntryBatch(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) + // Security: Agents are blocked entirely from batch import + // Batch import is a human UI convenience, agents should use single-entry API + if IsAgentRequest(r) { + ErrorResponse(w, http.StatusForbidden, "agent_forbidden", "Agents cannot use batch import") + return + } var batch []struct { Type string `json:"type"` Title string `json:"title"` @@ -472,6 +615,11 @@ func (h *Handlers) CreateEntryBatch(w http.ResponseWriter, r *http.Request) { if req.Type == "" { req.Type = lib.TypeCredential } + // Security: Return 403 immediately on forbidden types (don't silently skip) + if req.Type == lib.TypeAgent || req.Type == lib.TypeScope { + ErrorResponse(w, http.StatusForbidden, "system_type", "Cannot create agent or scope entries via batch import") + return + } // Upsert: find existing by title, update if exists, create if not existing, _ := lib.EntrySearchFuzzy(db, vk, req.Title) var match *lib.Entry @@ -484,6 +632,8 @@ func (h *Handlers) CreateEntryBatch(w http.ResponseWriter, r *http.Request) { if match != nil { match.Type = req.Type if req.Data != nil { + // Security: Preserve L3 fields during batch update + rejectAgentL3Overwrite(w, match.VaultData, req.Data) match.VaultData = req.Data } if lib.EntryUpdate(db, vk, match) == nil { @@ -519,6 +669,9 @@ func (h *Handlers) UpsertEntry(w http.ResponseWriter, r *http.Request) { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } + if rejectAgentSystemWrite(w, r, req.Type) { + return + } if req.Title == "" { ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required") return @@ -545,6 +698,10 @@ func (h *Handlers) UpsertEntry(w http.ResponseWriter, r *http.Request) { match.Type = req.Type match.ParentID = req.ParentID if req.Data != nil { + // Security: Agents cannot overwrite L3 fields + if IsAgentRequest(r) { + rejectAgentL3Overwrite(w, match.VaultData, req.Data) + } match.VaultData = req.Data } if err := lib.EntryUpdate(h.db(r), h.vk(r), match); err != nil { @@ -591,6 +748,9 @@ func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } + if rejectAgentSystemWrite(w, r, req.Type) { + return + } existing, err := lib.EntryGet(h.db(r), h.vk(r), entryID) if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") @@ -609,8 +769,17 @@ func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { existing.ParentID = req.ParentID existing.Version = req.Version if req.Data != nil { + // Agents cannot overwrite L3 fields — preserve existing L3 values + if IsAgentRequest(r) { + rejectAgentL3Overwrite(w, existing.VaultData, req.Data) + } existing.VaultData = req.Data } + // Agents cannot change entry type to a system type + if IsAgentRequest(r) && (existing.Type == lib.TypeAgent || existing.Type == lib.TypeScope) { + ErrorResponse(w, http.StatusForbidden, "system_type", "Agents cannot modify agent or scope records") + return + } if err := lib.EntryUpdate(h.db(r), h.vk(r), existing); err != nil { if err == lib.ErrVersionConflict { ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error()) @@ -627,27 +796,33 @@ func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) { + agent := h.agent(r) actor := ActorFromContext(r.Context()) entryID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } - entry, _ := lib.EntryGet(h.db(r), h.vk(r), entryID) + entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID) + if err == lib.ErrNotFound { + ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") + return + } + if err != nil { + ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry") + return + } + // Security: Check scope access before deletion + if !lib.AgentCanAccess(agent, entry.Scopes) { + ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied") + return + } if err := lib.EntryDelete(h.db(r), entryID); err != nil { - if err == lib.ErrNotFound { - ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") - return - } ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry") return } - title := "" - if entry != nil { - title = entry.Title - } lib.AuditLog(h.db(r), &lib.AuditEvent{ - EntryID: lib.HexID(entryID), Title: title, Action: lib.ActionDelete, + EntryID: lib.HexID(entryID), Title: entry.Title, Action: lib.ActionDelete, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) @@ -682,6 +857,11 @@ func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) { } } } + // Security: Log search to audit trail + lib.AuditLog(h.db(r), &lib.AuditEvent{ + Action: lib.ActionRead, Actor: actor, IPAddr: realIP(r), + Title: fmt.Sprintf("search: %q (%d results)", query, len(entries)), + }) JSONResponse(w, http.StatusOK, entries) } @@ -872,7 +1052,7 @@ func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) { // --------------------------------------------------------------------------- func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) { - if h.requireOwner(w, r) { + if h.requireAdmin(w, r) { return } var req struct { @@ -889,7 +1069,9 @@ func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) { ErrorResponse(w, http.StatusBadRequest, "missing_name", "Name is required") return } - + // DESIGN NOTE: Empty scopes with all_access=false is intentional. + // This allows users to create a "blocked" agent that cannot access any entries, + // effectively quarantining a rogue agent without deleting it. agent, credential, err := lib.AgentCreate(h.db(r), h.vk(r), h.l0(r), req.Name, req.Scopes, req.AllAccess, req.Admin) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent") @@ -912,7 +1094,7 @@ func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) { - if h.requireOwner(w, r) { + if h.requireAdmin(w, r) { return } entries, err := lib.EntryList(h.db(r), h.vk(r), nil) @@ -942,7 +1124,7 @@ func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) { - if h.requireOwner(w, r) { + if h.requireAdmin(w, r) { return } entryID, err := lib.HexToID(chi.URLParam(r, "id")) @@ -967,7 +1149,7 @@ func (h *Handlers) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) HandleUpdateEntryScopes(w http.ResponseWriter, r *http.Request) { - if h.requireOwner(w, r) { + if h.requireAdmin(w, r) { return } entryID, err := lib.HexToID(chi.URLParam(r, "id")) @@ -982,6 +1164,14 @@ func (h *Handlers) HandleUpdateEntryScopes(w http.ResponseWriter, r *http.Reques ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } + // Security: Validate scope format. Invalid format indicates possible data corruption. + if err := validateScopeFormat(req.Scopes); err != nil { + // Community: Log to stderr. Commercial: Also POSTs to telemetry endpoint. + edition.Current.AlertOperator(r.Context(), "data_corruption", + "Invalid scope format detected", map[string]any{"entry_id": entryID, "scopes": req.Scopes, "error": err.Error()}) + ErrorResponse(w, http.StatusBadRequest, "invalid_scopes", "Invalid scope format - possible data corruption") + return + } if err := lib.EntryUpdateScopes(h.db(r), entryID, req.Scopes); err != nil { if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") @@ -1068,10 +1258,12 @@ func (h *Handlers) HandleWebAuthnAuthBegin(w http.ResponseWriter, r *http.Reques prfSalt = c.PRFSalt } } - prfExt := map[string]any{} - if len(prfSalt) > 0 { - prfExt["eval"] = map[string]any{"first": prfSalt} + // Security: All credentials must have PRF enabled. No non-PRF fallbacks. + if len(prfSalt) == 0 { + ErrorResponse(w, http.StatusInternalServerError, "no_prf", "No PRF-enabled credentials found") + return } + prfExt := map[string]any{"eval": map[string]any{"first": prfSalt}} JSONResponse(w, http.StatusOK, map[string]any{ "publicKey": map[string]any{ "challenge": challenge, "allowCredentials": allowCreds, @@ -1082,6 +1274,7 @@ func (h *Handlers) HandleWebAuthnAuthBegin(w http.ResponseWriter, r *http.Reques func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Request) { var req struct { + Challenge []byte `json:"challenge"` CredID lib.HexID `json:"cred_id"` SignCount int `json:"sign_count"` } @@ -1089,6 +1282,11 @@ func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Req ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } + // Security: Verify the challenge was issued by us + if err := h.consumeChallenge(req.Challenge, "webauthn-auth"); err != nil { + ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed") + return + } lib.UpdateWebAuthnSignCount(h.db(r), int64(req.CredID), req.SignCount) JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"}) } @@ -1138,6 +1336,9 @@ func (h *Handlers) CreateBackup(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) { + if h.requireAdmin(w, r) { + return + } var req struct { Name string `json:"name"` } @@ -1149,6 +1350,10 @@ func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) { ErrorResponse(w, http.StatusInternalServerError, "restore_error", err.Error()) return } + lib.AuditLog(h.db(r), &lib.AuditEvent{ + Action: lib.ActionBackupRestore, Actor: ActorFromContext(r.Context()), + IPAddr: realIP(r), Title: req.Name, + }) JSONResponse(w, http.StatusOK, map[string]string{"status": "restored", "name": req.Name}) } @@ -1177,6 +1382,26 @@ func extractDomain(urlStr string) string { return urlStr } +// validateScopeFormat validates that scopes is comma-separated hex IDs (16 chars each). +// Empty string is valid (no scopes). Returns error for invalid format. +func validateScopeFormat(scopes string) error { + if scopes == "" { + return nil + } + for _, s := range strings.Split(scopes, ",") { + s = strings.TrimSpace(s) + if len(s) != 16 { + return fmt.Errorf("invalid scope ID length: %q (expected 16 hex chars)", s) + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return fmt.Errorf("invalid scope ID characters: %q (expected hex only)", s) + } + } + } + return nil +} + func generateTOTPSecret() string { b := make([]byte, 20) rand.Read(b) diff --git a/clavis/clavis-vault/api/middleware.go b/clavis/clavis-vault/api/middleware.go index b2e0f2e..ec50f56 100644 --- a/clavis/clavis-vault/api/middleware.go +++ b/clavis/clavis-vault/api/middleware.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/johanj/clavitor/edition" "github.com/johanj/clavitor/lib" ) @@ -117,8 +118,11 @@ func L1Middleware(dataDir string) func(http.Handler) http.Handler { agentIDHex := hex.EncodeToString(agentID) agent, err := lib.AgentLookup(db, l1Key, agentIDHex) if err != nil { - log.Printf("agent lookup error: %v", err) - ErrorResponse(w, http.StatusInternalServerError, "agent_error", "Agent lookup failed") + // Community: Log to stderr. Commercial: Also POSTs to telemetry endpoint. + // This indicates DB corruption, decryption failure, or disk issues. + edition.Current.AlertOperator(r.Context(), "auth_system_error", + "Agent lookup failed (DB/decryption error)", map[string]any{"error": err.Error()}) + ErrorResponse(w, http.StatusInternalServerError, "system_error", "Authentication system error - contact support") return } if agent == nil { @@ -126,6 +130,47 @@ func L1Middleware(dataDir string) func(http.Handler) http.Handler { return } + clientIP := realIP(r) + + // IP whitelist: first contact fills it, subsequent requests checked + if agent.AllowedIPs == "" { + // First contact — record the IP + // + // SECURITY NOTE: There is a theoretical race condition here. + // If two parallel requests from different IPs arrive simultaneously + // for the same agent's first contact, both could pass the empty check + // before either writes to the database. + // + // This was reviewed and accepted because: + // 1. Requires a stolen agent token (already a compromise scenario) + // 2. Requires two agents with the same token racing first contact + // 3. The "loser" simply won't be auto-whitelisted (one IP wins) + // 4. Cannot be reproduced in testing; practically impossible to trigger + // 5. Per-vault SQLite isolation limits blast radius + // + // The fix would require plaintext allowed_ips column + atomic conditional + // update. Not worth the complexity for this edge case. + agent.AllowedIPs = clientIP + if err := lib.AgentUpdateAllowedIPs(db, l1Key, agent); err != nil { + log.Printf("agent %s: failed to record first-contact IP: %v", agent.Name, err) + ErrorResponse(w, http.StatusInternalServerError, "ip_record_failed", "Failed to record agent IP") + return + } + log.Printf("agent %s: first contact from %s, IP recorded", agent.Name, clientIP) + } else if !lib.AgentIPAllowed(agent, clientIP) { + log.Printf("agent %s: blocked IP %s (allowed: %s)", agent.Name, clientIP, agent.AllowedIPs) + ErrorResponse(w, http.StatusForbidden, "ip_blocked", "IP not allowed for this agent") + return + } + + // Per-agent rate limiting + if agent.RateLimit > 0 { + if !agentRateLimiter.allow(agent.AgentID, agent.RateLimit) { + ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Agent rate limit exceeded") + return + } + } + ctx := context.WithValue(r.Context(), ctxDB, db) ctx = context.WithValue(ctx, ctxVaultKey, l1Key) ctx = context.WithValue(ctx, ctxActor, lib.ActorAgent) @@ -234,6 +279,45 @@ type rateLimitEntry struct { count int } +// Per-agent rate limiter (keyed by agent ID, not IP). +var agentRateLimiter = newAgentLimiter() + +type agentLimiter struct { + mu sync.Mutex + agents map[string]*rateLimitEntry +} + +func newAgentLimiter() *agentLimiter { + al := &agentLimiter{agents: make(map[string]*rateLimitEntry)} + go func() { + for { + time.Sleep(time.Minute) + al.mu.Lock() + now := time.Now() + for id, e := range al.agents { + if now.Sub(e.windowStart) > time.Minute { + delete(al.agents, id) + } + } + al.mu.Unlock() + } + }() + return al +} + +func (al *agentLimiter) allow(agentID string, maxPerMinute int) bool { + al.mu.Lock() + defer al.mu.Unlock() + now := time.Now() + e, exists := al.agents[agentID] + if !exists || now.Sub(e.windowStart) > time.Minute { + e = &rateLimitEntry{windowStart: now, count: 0} + al.agents[agentID] = e + } + e.count++ + return e.count <= maxPerMinute +} + // CORSMiddleware handles CORS headers. func CORSMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -260,11 +344,47 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler { 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") - 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") + // CSP: removed unused tailwindcss, tightened img-src to self+data only + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' localhost 127.0.0.1 https://clavitor.ai") next.ServeHTTP(w, r) }) } +// MaxBodySizeMiddleware limits request body size and rejects binary content. +// Allows 64KB max for markdown notes. Rejects binary data (images, executables, etc). +func MaxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Security: Reject binary content types + contentType := r.Header.Get("Content-Type") + if isBinaryContentType(contentType) { + ErrorResponse(w, http.StatusUnsupportedMediaType, "binary_not_allowed", + "Binary content not allowed. Only text/markdown data accepted.") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + next.ServeHTTP(w, r) + }) + } +} + +// isBinaryContentType detects common binary content types. +func isBinaryContentType(ct string) bool { + ct = strings.ToLower(ct) + binaryTypes := []string{ + "image/", "audio/", "video/", "application/pdf", + "application/zip", "application/gzip", "application/octet-stream", + "application/x-executable", "application/x-dosexec", + "multipart/form-data", // usually file uploads + } + for _, bt := range binaryTypes { + if strings.Contains(ct, bt) { + return true + } + } + return false +} + // ErrorResponse sends a JSON error response. func ErrorResponse(w http.ResponseWriter, status int, code, message string) { w.Header().Set("Content-Type", "application/json") diff --git a/clavis/clavis-vault/api/routes.go b/clavis/clavis-vault/api/routes.go index 31bfa9f..9b4cbfc 100644 --- a/clavis/clavis-vault/api/routes.go +++ b/clavis/clavis-vault/api/routes.go @@ -19,6 +19,9 @@ func NewRouter(cfg *lib.Config, webFS embed.FS) *chi.Mux { r.Use(LoggingMiddleware) r.Use(CORSMiddleware) r.Use(SecurityHeadersMiddleware) + // Security: Limit request body to 64KB. Rejects binary uploads (images, executables). + // Markdown notes and text data only. Returns 413 if exceeded, 415 for binary. + r.Use(MaxBodySizeMiddleware(65536)) r.Use(RateLimitMiddleware(120)) r.Use(L1Middleware(cfg.DataDir)) @@ -124,7 +127,11 @@ func mountAPIRoutes(r chi.Router, h *Handlers) { r.Post("/backups", h.CreateBackup) r.Post("/backups/restore", h.RestoreBackup) - // Agent management (owner-only) + // Admin auth (PRF tap required for admin operations) + r.Post("/auth/admin/begin", h.AdminAuthBegin) + r.Post("/auth/admin/complete", h.AdminAuthComplete) + + // Agent management (admin-only — requires PRF tap + admin token) r.Post("/agents", h.HandleCreateAgent) r.Get("/agents", h.HandleListAgents) r.Delete("/agents/{id}", h.HandleDeleteAgent) diff --git a/clavis/clavis-vault/cmd/._.DS_Store b/clavis/clavis-vault/cmd/._.DS_Store deleted file mode 100644 index 28c42fb..0000000 Binary files a/clavis/clavis-vault/cmd/._.DS_Store and /dev/null differ diff --git a/clavis/clavis-vault/cmd/clavitor/._.DS_Store b/clavis/clavis-vault/cmd/clavitor/._.DS_Store deleted file mode 100644 index 28c42fb..0000000 Binary files a/clavis/clavis-vault/cmd/clavitor/._.DS_Store and /dev/null differ diff --git a/clavis/clavis-vault/cmd/clavitor/main.go b/clavis/clavis-vault/cmd/clavitor/main.go index 764c501..e81c03c 100644 --- a/clavis/clavis-vault/cmd/clavitor/main.go +++ b/clavis/clavis-vault/cmd/clavitor/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "embed" "flag" "log" @@ -8,6 +9,7 @@ import ( "strconv" "github.com/johanj/clavitor/api" + "github.com/johanj/clavitor/edition" "github.com/johanj/clavitor/lib" ) @@ -25,9 +27,12 @@ func main() { port := flag.Int("port", envInt("PORT", 443), "Listen port") backupURL := flag.String("backup-url", envStr("BACKUP_URL", ""), "Backup vault URL for replication") + + // Telemetry flags (commercial edition only, ignored in community) telemetryFreq := flag.Int("telemetry-freq", envInt("TELEMETRY_FREQ", 0), "Telemetry POST interval in seconds (0 = disabled)") telemetryHost := flag.String("telemetry-host", envStr("TELEMETRY_HOST", ""), "Telemetry endpoint URL") telemetryToken := flag.String("telemetry-token", envStr("TELEMETRY_TOKEN", ""), "Bearer token for telemetry endpoint") + popRegion := flag.String("pop-region", envStr("POP_REGION", ""), "POP region identifier (commercial only)") flag.Parse() _ = backupURL // TODO: wire up replication @@ -38,13 +43,32 @@ func main() { } cfg.Port = strconv.Itoa(*port) - lib.StartTelemetry(lib.TelemetryConfig{ - FreqSeconds: *telemetryFreq, - Host: *telemetryHost, - Token: *telemetryToken, - DataDir: cfg.DataDir, - Version: version, - }) + // Initialize edition-specific configuration + log.Printf("Starting Clavitor Vault %s - %s Edition", version, edition.Current.Name()) + + if edition.Current.Name() == "commercial" { + // Commercial: Set up centralized telemetry and alerting + edition.SetCommercialConfig(&edition.CommercialConfig{ + TelemetryHost: *telemetryHost, + TelemetryToken: *telemetryToken, + TelemetryFreq: *telemetryFreq, + POPRegion: *popRegion, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + edition.StartTelemetry(ctx) + } else { + // Community: Telemetry disabled by default, can be enabled manually + if *telemetryHost != "" { + lib.StartTelemetry(lib.TelemetryConfig{ + FreqSeconds: *telemetryFreq, + Host: *telemetryHost, + Token: *telemetryToken, + DataDir: cfg.DataDir, + Version: version, + }) + } + } lib.StartBackupTimer(cfg.DataDir) diff --git a/clavis/clavis-vault/cmd/clavitor/web/SPEC-import-matrix.md b/clavis/clavis-vault/cmd/clavitor/web/SPEC-import-matrix.md new file mode 100644 index 0000000..71c0b56 --- /dev/null +++ b/clavis/clavis-vault/cmd/clavitor/web/SPEC-import-matrix.md @@ -0,0 +1,271 @@ +# Import Matrix — Field Mapping + +All tables use the same columns. Empty cells = competitor does not support that field for that type. + +## Field Kinds + +`kind` describes the data type for rendering. NOT sensitivity — that's `tier`. + +| Kind | Rendering | Examples | +|---|---|---| +| `text` | Plain text | username, cardholder, city, ssn, cvv, password | +| `email` | mailto: link | email fields | +| `phone` | tel: link | phone fields | +| `totp` | TOTP code generator | totp seeds / otpauth URIs | +| `url` | Clickable link | hostname fields | +| `date` | Formatted date | expiry, purchase_date | + +## Encryption Tiers + +`tier` determines encryption and visibility. Independent of `kind`. + +| Tier | Encryption | Who can decrypt | Examples | +|---|---|---|---| +| L1 | Server-side (AES-GCM with L1 key) | Server, all agents | title, urls, notes, username, email, cardholder, address, expiry | +| **L2** | Client-side (PRF-derived L2 key) | CLI, extension, agents with L2 | password, totp, license_key, passphrase, db password, wifi password | +| **L3** | Client-side (PRF-derived L3 key) | Hardware tap only | card number, cvv, pin, ssn, passport, license, private_key, seed_phrase | + +## Canonical Field Definitions + +Every field we store. The `label`, `kind`, and `tier` are authoritative. + +### Credential fields +| Label | Kind | Tier | +|---|---|---| +| username | text | L1 | +| password | text | **L2** | +| totp | totp | **L2** | +| email | email | L1 | + +### Card fields +| Label | Kind | Tier | +|---|---|---| +| cardholder | text | L1 | +| number | text | **L3** | +| cvv | text | **L3** | +| pin | text | **L3** | +| brand | text | L1 | + +### Identity fields +| Label | Kind | Tier | +|---|---|---| +| first_name | text | L1 | +| last_name | text | L1 | +| middle_name | text | L1 | +| email | email | L1 | +| phone | phone | L1 | +| address1 | text | L1 | +| address2 | text | L1 | +| city | text | L1 | +| state | text | L1 | +| zip | text | L1 | +| country | text | L1 | +| company | text | L1 | +| ssn | text | **L3** | +| passport | text | **L3** | +| license | text | **L3** | + +### SSH Key fields +| Label | Kind | Tier | +|---|---|---| +| public_key | text | L1 | +| private_key | text | **L3** | +| passphrase | text | **L2** | +| fingerprint | text | L1 | +| key_type | text | L1 | + +### Software License fields +| Label | Kind | Tier | +|---|---|---| +| license_key | text | **L2** | +| version | text | L1 | +| publisher | text | L1 | +| purchase_date | date | L1 | +| email | email | L1 | + +### Database fields +| Label | Kind | Tier | +|---|---|---| +| db_type | text | L1 | +| hostname | url | L1 | +| port | text | L1 | +| database | text | L1 | +| username | text | L1 | +| password | text | **L2** | +| sid | text | L1 | +| connection_string | text | **L2** | + +### Wi-Fi fields +| Label | Kind | Tier | +|---|---|---| +| ssid | text | L1 | +| password | text | **L2** | +| encryption | text | L1 | + +### Server fields +| Label | Kind | Tier | +|---|---|---| +| hostname | url | L1 | +| username | text | L1 | +| password | text | **L2** | + +### Crypto Wallet fields +| Label | Kind | Tier | +|---|---|---| +| seed_phrase | text | **L3** | +| private_key | text | **L3** | +| wallet_address | text | L1 | +| derivation_path | text | L1 | +| network | text | L1 | +| passphrase | text | **L2** | + +--- + +## Competitor Mapping + +## Credential + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | `metadata.name` | `name` | `overview.title` | `name` | `title` | `Title` | `name` | `title` | `Name` | `title` | `name` | `hostname` | `Title` | `Title` | +| **urls** | — | L1 | `content.urls[]` | `login.uris[].uri` | `overview.urls[]` | `url` | `domain` | `URL` | `url` | `login_url` | `Url` | field type=`url` | `url` | `url` | `URL` | `URL` | +| **notes** | — | L1 | `metadata.note` | `notes` | `details.notesPlain` | `extra` | `note` | `Notes` | `note` | `notes` | `Note` | `notes` | | | `Notes` | `Notes` | +| username | text | L1 | `content.itemEmail` / `itemUsername` | `login.username` | designation=`username` | `username` | `email` / `login` | `UserName` | `username` | `login` | `Login` | type=`username` | `username` | `username` | `Username` | `Username` | +| password | password | **L2** | `content.password` | `login.password` | designation=`password` | `password` | `password` | `Password` | `Password` | `Password` | `password` | `password` | `Pwd` | type=`password` | `password` | `password` | +| totp | totp | **L2** | `content.totpUri` | `login.totp` | field type=`otp` | `totp` | `otpSecret` | plugin: `TimeOtp-Secret-Base32` | | `$oneTimeCode` | | type=`totp` | | | `OTPAuth` | `TOTP` | +| email | text | L1 | `content.itemEmail` | | field label=`email` | | `email` | | | | | type=`email` | | | | | +| custom fields | per type | L1/L2 | `extraFields[]` | `fields[]` | `sections[].fields[]` | parsed from `extra` | | custom strings | | `custom_fields[]` | `RfFieldsV2` | extra fields | | | | | + +## Card + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | `metadata.name` | `name` | `overview.title` | `name` | `name` | `Title` | `name` | `title` | `Name` | `title` | | | +| **notes** | — | L1 | `metadata.note` | `notes` | `details.notesPlain` | `Notes` | `note` | `Notes` | `note` | `notes` | `Note` | `notes` | | | +| cardholder | text | L1 | `cardholderName` | `card.cardholderName` | field=`cardholder` | `Name on Card` | `holder` | | `cardholdername` | `cardholderName` | | label=`Cardholder` | | | +| number | password | **L3** | `number` | `card.number` | field=`ccnum` | `Number` | `cardNumber` | | `cardnumber` | `cardNumber` | | type=`credit_card` | | | +| cvv | password | **L3** | `verificationNumber` | `card.code` | field=`cvv` | `Security Code` | `securityCode` | | `cvc` | `cardSecurityCode` | | label=`CVV` | | | +| expiry | text | L1 | `expirationDate` | `expMonth`+`expYear` | field=`expiry` | `Expiration Date` | `expireDate` | | `expirydate` | `cardExpirationDate` | | label=`Expiry` | | | +| pin | password | **L3** | `pin` | | field=`pin` | | | | | `pinCode` | | | | | +| brand | text | L1 | `cardType` | `card.brand` | field=`type` | `Type` | `issuing_bank` | | | | | | | | + +## Identity + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | `metadata.name` | `name` | `overview.title` | `name` | `name` | `Title` | `name` | `title` | `Name` | `title` | | | +| **notes** | — | L1 | `metadata.note` | `notes` | `details.notesPlain` | `Notes` | `note` | `Notes` | | `notes` | `Note` | `notes` | | | +| first_name | text | L1 | `firstName` | `identity.firstName` | field=`firstname` | `First Name` | `firstName` | | `full_name` | `name.first` | `First Name` | label=`First name` | | | +| last_name | text | L1 | `lastName` | `identity.lastName` | field=`lastname` | `Last Name` | `lastName` | | | `name.last` | `Last Name` | label=`Last name` | | | +| middle_name | text | L1 | | `identity.middleName` | field=`initial` | `Middle Name` | `middleName` | | | `name.middle` | `Middle Name` | | | | +| email | text | L1 | `email` | `identity.email` | field=`email` | `Email` | `email` | | `email` | `email` | `Email` | type=`email` | | | +| phone | text | L1 | `phoneNumber` | `identity.phone` | field=`defphone` | `Phone` | `phone_number` | | `phone_number` | `phone.default` | `Phone` | type=`phone` | | | +| address1 | text | L1 | `streetAddress` | `identity.address1` | field=`address.street` | `Address 1` | `addressStreet` | | `address1` | `address.street` | `Address 1` | label=`Address` | | | +| address2 | text | L1 | | `identity.address2` | | `Address 2` | | | `address2` | | `Address 2` | | | | +| city | text | L1 | `city` | `identity.city` | field=`address.city` | `City / Town` | `addressCity` | | `city` | `address.city` | `City` | label=`City` | | | +| state | text | L1 | `stateOrProvince` | `identity.state` | field=`address.state` | `State` | `addressState` | | `state` | `address.state` | `State` | label=`State` | | | +| zip | text | L1 | `zipOrPostalCode` | `identity.postalCode` | field=`address.zip` | `Zip / Postal Code` | `addressZipcode` | | `zipcode` | `address.zip` | `Zip` | label=`ZIP` | | | +| country | text | L1 | `country` | `identity.country` | field=`address.country` | `Country` | `addressCountry` | | `country` | `address.country` | `Country` | label=`Country` | | | +| company | text | L1 | `organization` | `identity.company` | field=`company` | `Company` | | | | `company` | `Company` | label=`Company` | | | +| ssn | password | **L3** | `socialSecurityNumber` | `identity.ssn` | field=`socialsecurity` | `Social Security Number` | | | | `accountNumber` | | | | | +| passport | password | **L3** | `passportNumber` | `identity.passportNumber` | field=`passport` | `Passport Number` | | | | | | label=`Passport` | | | +| license | password | **L3** | `licenseNumber` | `identity.licenseNumber` | field=`license` | `Driver's License` | | | | | | label=`License` | | | + +## Note + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | `metadata.name` | `name` | `overview.title` | `name` | `title` | `Title` | `name` | `title` | `Name` | `title` | | | +| **notes** | — | **L2** | `metadata.note` | `notes` | `details.notesPlain` | `extra` | `content` | `Notes` | `note` | `notes` | `Note` | `notes` | | | + +## SSH Key + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | `overview.title` | `name` | | | | `title` | | | | | +| public_key | text | L1 | | | field=`public_key` | `Public Key` | | | | `keyPair.publicKey` | | | | | +| private_key | password | **L3** | | | field=`private_key` | `Private Key` | | | | `keyPair.privateKey` | | | | | +| passphrase | password | **L2** | | | field=`password` | `Passphrase` | | | | `passphrase` | | | | | +| fingerprint | text | L1 | | | field=`fingerprint` | | | | | | | | | | +| key_type | text | L1 | | | | | | | | | | | | | + +## Software License + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | `overview.title` | `name` | | | | | | | | | +| license_key | password | **L2** | | | field=`reg_code` | `License Key` | | | | | | | | | +| version | text | L1 | | | field=`product_version` | `Version` | | | | | | | | | +| publisher | text | L1 | | | field=`publisher_name` | | | | | | | | | | +| purchase_date | text | L1 | | | field=`order_date` | | | | | | | | | | +| email | text | L1 | | | field=`reg_email` | | | | | | | | | | + +## Database + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | `overview.title` | `name` | | | | | | | | | +| db_type | text | L1 | | | field=`database_type` | `Type` | | | | | | | | | +| hostname | text | L1 | | | field=`hostname` | `Hostname` | | | | | | | | | +| port | text | L1 | | | field=`port` | `Port` | | | | | | | | | +| database | text | L1 | | | field=`database` | `Database` | | | | | | | | | +| username | text | L1 | | | field=`username` | `Username` | | | | | | | | | +| password | password | **L2** | | | field=`password` | `Password` | | | | | | | | | +| sid | text | L1 | | | field=`options` | | | | | | | | | | +| connection_string | password | **L2** | | | | | | | | | | | | | + +## Wi-Fi + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | | `name` | | | | | | | | | +| ssid | text | L1 | | | | `SSID` | | | | | | | | | +| password | password | **L2** | | | | `Password` | | | | | | | | | +| encryption | text | L1 | | | | `Connection Type` | | | | | | | | | + +## Server + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | `overview.title` | `name` | | | | | | | | | +| hostname | text | L1 | | | field=`url` | `Hostname` | | | | | | | | | +| username | text | L1 | | | field=`username` | `Username` | | | | | | | | | +| password | password | **L2** | | | field=`password` | `Password` | | | | | | | | | + +## Crypto Wallet (Clavitor-only) + +| Clavitor Field | Kind | Tier | Proton Pass | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| **title** | — | L1 | | | | | | | | | | | | | +| seed_phrase | password | **L3** | | | | | | | | | | | | | +| private_key | password | **L3** | | | | | | | | | | | | | +| wallet_address | text | L1 | | | | | | | | | | | | | +| derivation_path | text | L1 | | | | | | | | | | | | | +| network | text | L1 | | | | | | | | | | | | | +| passphrase | password | **L2** | | | | | | | | | | | | | + +## Tier Summary + +| Tier | What | Examples | +|---|---|---| +| L1 | Server-readable. Titles, URLs, usernames, labels, metadata. | title, urls, notes, username, email, cardholder, expiry, address fields | +| **L2** | Agent-decryptable. Operational secrets that agents/extensions need. | password, totp, license_key, db password, wifi password, server password, ssh passphrase, note content | +| **L3** | Hardware tap only. PII, financial, government IDs. | card number, cvv, pin, ssn, passport, driver's license, private keys, seed phrases | + +## Coverage Summary + +| Feature | Proton | Bitwarden | 1Password | LastPass | Dashlane | KeePass | NordPass | Keeper | RoboForm | Enpass | Chrome | Firefox | Safari/iCloud | KeePassXC | **Clavitor** | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Credential | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **✓** | +| TOTP | ✓ | ✓ | ✓ | ✓ | ✓ | plugin | | ✓ | | ✓ | | | ✓ | ✓ | **✓** | +| Card | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | | ✓ | | | | | **✓** | +| Identity | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | | | | | **✓** | +| Note | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | | ✓ | **✓** | +| SSH Key | | | ✓ | ✓ | | | | ✓ | | | | | | | **✓** | +| License | | | ✓ | ✓ | | | | | | | | | | | **✓** | +| Database | | | ✓ | ✓ | | | | | | | | | | | **✓** | +| Wi-Fi | | | | ✓ | | | | | | | | | | | **✓** | +| Server | | | ✓ | ✓ | | | | | | | | | | | **✓** | +| Crypto Wallet | | | | | | | | | | | | | | | **✓** | +| Custom Fields | ✓ | ✓ | ✓ | ✓ | | ✓ | | ✓ | ✓ | ✓ | | | | ✓ | **✓** | +| **L2/L3 tiers** | | | | | | | | | | | | | | | **✓** | diff --git a/clavis/clavis-vault/cmd/clavitor/web/clavitor-app.css b/clavis/clavis-vault/cmd/clavitor/web/clavitor-app.css index 7cb3633..7ba7f8b 100644 --- a/clavis/clavis-vault/cmd/clavitor/web/clavitor-app.css +++ b/clavis/clavis-vault/cmd/clavitor/web/clavitor-app.css @@ -69,11 +69,12 @@ body.theme-light .vault-lock-banner { background: rgba(239,68,68,0.08); } /* === RESET === */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html, body { height: 100%; } +html, body, #app { height: 100%; } body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: 0.875rem; line-height: 1.6; } a { color: inherit; text-decoration: none; } button { font-family: inherit; font-size: inherit; cursor: pointer; border: none; background: none; color: inherit; } input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); } +input[type="checkbox"] { accent-color: var(--text); } /* === TYPOGRAPHY === */ h1 { font-size: 1.875rem; font-weight: 800; line-height: 1.1; color: var(--text); } @@ -180,17 +181,21 @@ p { color: var(--muted); line-height: 1.75; } .list-badge.type-ssh_key { color: var(--red); background: rgba(239,68,68,0.12); border-color: rgba(239,68,68,0.15); } .list-badge.type-totp { color: #a855f7; background: rgba(168,85,247,0.12); border-color: rgba(168,85,247,0.15); } -.entry-row { display: flex; align-items: center; gap: 0.875rem; padding: 0.75rem 1rem; border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer; transition: background 0.15s, transform 0.15s; } -.entry-row:hover { background: rgba(255,255,255,0.05); } +/* --- Shared item system (vault list, import list, agent list, etc.) --- */ +.item-row { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.15s, transform 0.15s; } +.item-row:hover { background: rgba(100,140,200,0.08); } +.item-row:active { transform: scale(0.995); } +.item-row.faded { opacity: 0.35; } +.item-icon { width: 2.75rem; height: 1.375rem; border-radius: 0.25rem; background: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.5rem; font-weight: 600; color: var(--bg); flex-shrink: 0; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; } +.item-title { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } +.item-sub { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.75rem; } +.item-list { display: flex; flex-direction: column; max-height: 60vh; overflow-y: auto; } + +/* Legacy aliases — vault list uses these, migrate later */ +.entry-row { display: flex; align-items: center; gap: 0.875rem; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.15s, transform 0.15s; } +.entry-row:hover { background: rgba(100,140,200,0.08); } .entry-row:active { transform: scale(0.995); } -.entry-icon { width: 2.75rem; height: 1.375rem; border-radius: 0.25rem; background: rgba(100,140,200,0.12); display: flex; align-items: center; justify-content: center; font-size: 0.5rem; font-weight: 600; color: var(--muted); flex-shrink: 0; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; } -.entry-icon.type-credential { background: rgba(74,222,128,0.1); color: var(--accent); } -.entry-icon.type-card { background: rgba(212,175,55,0.1); color: var(--gold); } -.entry-icon.type-identity { background: rgba(96,165,250,0.1); color: #60a5fa; } -.entry-icon.type-note { background: rgba(148,163,184,0.1); color: var(--muted); } -.entry-icon.type-ssh_key { background: rgba(239,68,68,0.1); color: var(--red); } -.entry-icon.type-totp { background: rgba(168,85,247,0.1); color: #a855f7; } -.entry-icon.type-folder { background: rgba(212,175,55,0.1); color: var(--gold); } +.entry-icon { width: 2.75rem; height: 1.375rem; border-radius: 0.25rem; background: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.5rem; font-weight: 600; color: var(--bg); flex-shrink: 0; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; } .entry-domain { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } .entry-user { color: var(--subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; } .entry-user::before { content: '·'; margin: 0 0.5rem; color: var(--subtle); } @@ -322,11 +327,6 @@ p { color: var(--muted); line-height: 1.75; } .import-summary { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; padding: 0.75rem; background: rgba(100,140,200,0.06); border-radius: var(--radius-sm); border: 1px solid var(--border); } .import-summary label { cursor: pointer; user-select: none; display: inline-flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; } -.import-list { max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; gap: 0.375rem; } -.import-item { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; background: rgba(100,140,200,0.08); border: 1px solid rgba(148,163,184,0.08); border-radius: var(--radius-sm); transition: background 0.15s; } -.import-item:hover { background: rgba(100,140,200,0.12); } -.import-item.faded { opacity: 0.35; } -.import-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } /* ============================================================ APP — Onboarding / Unlock diff --git a/clavis/clavis-vault/cmd/clavitor/web/import.html b/clavis/clavis-vault/cmd/clavitor/web/import.html index a11a23f..aee81b5 100644 --- a/clavis/clavis-vault/cmd/clavitor/web/import.html +++ b/clavis/clavis-vault/cmd/clavitor/web/import.html @@ -17,40 +17,42 @@
-
+