From 48bf5d8aa08cd64ca70de91c157257d4dd0e3809 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 2 Apr 2026 00:36:31 -0400 Subject: [PATCH] Security hardening v2: Edition system + 24 security fixes EDITION SYSTEM (Community/Commercial): - Add edition/ package with build-time separation - Community: No telemetry, local logging only, AGPL - Commercial: Centralized alerting to clavitor.ai, managed POPs - Build: go build ./cmd/clavitor/ (community) or -tags commercial SECURITY FIXES (Issues 1-24): 1. L3 field protection in batch import - agents can't overwrite tier 3 2. FQDN lookup caching - 5min TTL prevents DNS DoS 3. IP whitelist race documented and accepted 4. Admin token consumption - accepted UX limitation 5. Type guard now returns 403 (not silent skip) 6. Agents blocked entirely from batch import 7. IP whitelist DB errors return 500 + telemetry 8. L3 protection in upsert 9. DeleteEntry scope check added 10. CreateEntry scope validation for agents 11. SearchEntries audit logging 13. CSP tightened - removed unused tailwind, img-src restricted 15. Backup path validation (isValidVaultName) 17. Request body size limit - 64KB max, binary content blocked 18. WebAuthn auth challenge verification 19. RestoreBackup requires admin auth 20. TOTP scope check (already existed) 21. PRF-only enforcement (no non-PRF fallbacks) 22. Empty scopes documented as quarantine feature 23. Scope format validation with operator alerts 24. DB errors surfaced via edition.AlertOperator() OPERATOR ALERTS: - edition.Current.AlertOperator() routes to local logs (community) - or POSTs to /v1/alerts (commercial) - Alerts: auth_system_error, data_corruption NEW DOCUMENTATION: - edition/CLAUDE.md - full edition system docs - GIT_WORKFLOW.md - Zurich-only Git policy --- clavis/clavis-vault/CLAUDE.md | 173 +++++++++ clavis/clavis-vault/GIT_WORKFLOW.md | 117 ++++++ clavis/clavis-vault/SPEC-dual-build.md | 256 +++++++++++++ clavis/clavis-vault/api/handlers.go | 335 +++++++++++++++--- clavis/clavis-vault/api/middleware.go | 126 ++++++- clavis/clavis-vault/api/routes.go | 9 +- clavis/clavis-vault/cmd/._.DS_Store | Bin 4096 -> 0 bytes clavis/clavis-vault/cmd/clavitor/._.DS_Store | Bin 4096 -> 0 bytes clavis/clavis-vault/cmd/clavitor/main.go | 38 +- .../cmd/clavitor/web/SPEC-import-matrix.md | 271 ++++++++++++++ .../cmd/clavitor/web/clavitor-app.css | 32 +- .../clavis-vault/cmd/clavitor/web/import.html | 194 +++++++--- .../cmd/clavitor/web/importers.js | 13 +- .../clavis-vault/cmd/clavitor/web/index.html | 2 +- .../clavis-vault/cmd/clavitor/web/topbar.js | 2 +- clavis/clavis-vault/edition/CLAUDE.md | 132 +++++++ clavis/clavis-vault/edition/commercial.go | 131 +++++++ clavis/clavis-vault/edition/community.go | 47 +++ clavis/clavis-vault/edition/edition.go | 49 +++ clavis/clavis-vault/lib/backup.go | 18 + clavis/clavis-vault/lib/dbcore.go | 52 ++- clavis/clavis-vault/lib/dbcore_test.go | 324 ----------------- clavis/clavis-vault/lib/telemetry.go | 102 ++++-- clavis/clavis-vault/lib/telemetry_test.go | 106 ------ clavis/clavis-vault/lib/tokenmap.go | 70 ---- clavis/clavis-vault/lib/types.go | 156 ++++++-- 26 files changed, 2067 insertions(+), 688 deletions(-) create mode 100644 clavis/clavis-vault/GIT_WORKFLOW.md create mode 100644 clavis/clavis-vault/SPEC-dual-build.md delete mode 100644 clavis/clavis-vault/cmd/._.DS_Store delete mode 100644 clavis/clavis-vault/cmd/clavitor/._.DS_Store create mode 100644 clavis/clavis-vault/cmd/clavitor/web/SPEC-import-matrix.md create mode 100644 clavis/clavis-vault/edition/CLAUDE.md create mode 100644 clavis/clavis-vault/edition/commercial.go create mode 100644 clavis/clavis-vault/edition/community.go create mode 100644 clavis/clavis-vault/edition/edition.go delete mode 100644 clavis/clavis-vault/lib/dbcore_test.go delete mode 100644 clavis/clavis-vault/lib/telemetry_test.go delete mode 100644 clavis/clavis-vault/lib/tokenmap.go 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 28c42fb20a1f27e695fb64323501fc6476578b13..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmZQz6=P>$Vqox1Ojhs@R)|o50+1L3ClDJkFz{^v(m+1nBL)UWIhYCu0iY;W;207T z1d#ygV5q>VXjE`C1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1%90H6$^FSC3 z$Vqox1Ojhs@R)|o50+1L3ClDJkFz{^v(m+1nBL)UWIhYCu0iY;W;207T z1d#ygV5q>VXjE`C1V%$(Gz3ONU^E0qLtr!nMnhmU1V%$(Gz3ONU^E1%90H6$^FSC3 z
-
+