diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fa39e06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# Clavitor — Agent Instructions + +## Foundation First — No Mediocrity. Ever. + +The rule is simple: do it right, or say something. + +Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way. + +### What this means in practice + +If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom. + +If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now. + +Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution. + +Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases. + +### The restart rule + +When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike. + +### Q&D is research, not output + +Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts. + +### When you hit a bad foundation + +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. diff --git a/clavis/clavis-cli/CLAUDE.md b/clavis/clavis-cli/CLAUDE.md index 6bdc7dd..cdd5f13 100644 --- a/clavis/clavis-cli/CLAUDE.md +++ b/clavis/clavis-cli/CLAUDE.md @@ -46,6 +46,36 @@ All API calls go to `https://:1984` with `Authorization: Bearer ` and Endpoints used: `/api/entries`, `/api/search?q=`, `/api/entries/`, `/api/ext/totp/`. +## ⚒️ Foundation First — No Mediocrity. Ever. + +The rule is simple: do it right, or say something. + +Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way. + +### What this means in practice + +If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom. + +If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now. + +Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution. + +Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases. + +### The restart rule + +When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike. + +### Q&D is research, not output + +Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts. + +### When you hit a bad foundation + +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. + ## Testing ``` diff --git a/clavis/clavis-vault/CLAUDE.md b/clavis/clavis-vault/CLAUDE.md new file mode 100644 index 0000000..7a49a21 --- /dev/null +++ b/clavis/clavis-vault/CLAUDE.md @@ -0,0 +1,31 @@ +# Clavis Vault — CLAUDE.md + +## Foundation First — No Mediocrity. Ever. + +The rule is simple: do it right, or say something. + +Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way. + +### What this means in practice + +If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom. + +If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now. + +Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution. + +Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases. + +### The restart rule + +When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike. + +### Q&D is research, not output + +Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts. + +### When you hit a bad foundation + +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. diff --git a/clavis/clavis-vault/api/handlers.go b/clavis/clavis-vault/api/handlers.go index 3851711..3c13326 100644 --- a/clavis/clavis-vault/api/handlers.go +++ b/clavis/clavis-vault/api/handlers.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "log" - "net" "net/http" "path/filepath" "strconv" @@ -449,8 +448,8 @@ func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { entries = []lib.Entry{} } - // Strip L2 field values for MCP/agent actors; web/extension decrypt client-side - if actor == lib.ActorMCP || actor == lib.ActorAgent { + // Strip L2 field values for agent actors; web/extension decrypt client-side + if actor == lib.ActorAgent { for i := range entries { if entries[i].VaultData != nil { stripL2Fields(entries[i].VaultData) @@ -486,8 +485,8 @@ func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) { return } - // Strip L2 field values for MCP actors only; web/extension decrypt client-side - if actor == lib.ActorMCP && entry.VaultData != nil { + // Strip L2 field values for agent actors; web/extension decrypt client-side + if actor == lib.ActorAgent && entry.VaultData != nil { stripL2Fields(entry.VaultData) } @@ -674,8 +673,8 @@ func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) { entries = []lib.Entry{} } - // Strip L2 field values for MCP/agent actors; web/extension decrypt client-side - if actor == lib.ActorMCP || actor == lib.ActorAgent { + // Strip L2 field values for agent actors; web/extension decrypt client-side + if actor == lib.ActorAgent { for i := range entries { if entries[i].VaultData != nil { stripL2Fields(entries[i].VaultData) @@ -1082,354 +1081,6 @@ func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) { JSONResponse(w, http.StatusOK, events) } -// --------------------------------------------------------------------------- -// MCP Endpoint -// --------------------------------------------------------------------------- - -// MCPHandler handles JSON-RPC 2.0 MCP protocol requests. -func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) { - var req struct { - JSONRPC string `json:"jsonrpc"` - ID any `json:"id"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - mcpError(w, nil, -32700, "Parse error") - return - } - - if req.JSONRPC != "2.0" { - mcpError(w, req.ID, -32600, "Invalid Request") - return - } - - // Check read-only enforcement for scoped MCP tokens - mcpToken := MCPTokenFromContext(r.Context()) - if mcpToken != nil && mcpToken.ReadOnly { - // Parse the call to check for write methods - var call struct { - Name string `json:"name"` - } - if req.Method == "tools/call" { - json.Unmarshal(req.Params, &call) - // Block write operations on read-only tokens - if call.Name == "create_credential" || call.Name == "update_credential" || call.Name == "delete_credential" { - mcpError(w, req.ID, -32000, "token is read-only") - return - } - } - } - - var result any - var err error - - switch req.Method { - case "tools/list": - result = h.mcpToolsList() - case "tools/call": - result, err = h.mcpToolsCall(r, req.Params) - default: - mcpError(w, req.ID, -32601, "Method not found") - return - } - - if err != nil { - mcpError(w, req.ID, -32000, err.Error()) - return - } - - mcpSuccess(w, req.ID, result) -} - -func (h *Handlers) mcpToolsList() map[string]any { - return map[string]any{ - "tools": []map[string]any{ - { - "name": "get_credential", - "description": "Search and return a credential from the vault. L2 fields are omitted.", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]string{"type": "string", "description": "Search query (title or URL)"}, - }, - "required": []string{"query"}, - }, - }, - { - "name": "list_credentials", - "description": "List all credentials in the vault (titles, types, URLs only).", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "filter": map[string]string{"type": "string", "description": "Optional type filter"}, - }, - }, - }, - { - "name": "get_totp", - "description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]string{"type": "string", "description": "Entry title or ID"}, - }, - "required": []string{"query"}, - }, - }, - { - "name": "search_vault", - "description": "Search the vault by title.", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "query": map[string]string{"type": "string", "description": "Search query"}, - }, - "required": []string{"query"}, - }, - }, - { - "name": "check_expiring", - "description": "Check for entries with expiring credentials.", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "days": map[string]any{"type": "number", "description": "Days to check (default 30)"}, - }, - }, - }, - }, - } -} - -func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) { - var call struct { - Name string `json:"name"` - Arguments map[string]any `json:"arguments"` - } - if err := json.Unmarshal(params, &call); err != nil { - return nil, fmt.Errorf("invalid params") - } - - mcpToken := MCPTokenFromContext(r.Context()) - - switch call.Name { - case "get_credential", "search_vault": - query, _ := call.Arguments["query"].(string) - if query == "" { - return nil, fmt.Errorf("query is required") - } - entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query) - if err != nil { - return nil, err - } - entries = mcpFilterEntries(entries, mcpToken) - // Strip L2 fields - for i := range entries { - if entries[i].VaultData != nil { - stripL2Fields(entries[i].VaultData) - } - } - if len(entries) == 0 { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil - } - // For get_credential, return best match - if call.Name == "get_credential" { - result, _ := json.Marshal(entries[0]) - return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil - } - result, _ := json.Marshal(entries) - return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil - - case "list_credentials": - filter, _ := call.Arguments["filter"].(string) - entries, err := lib.EntryList(h.db(r), h.vk(r), nil) - if err != nil { - return nil, err - } - entries = mcpFilterEntries(entries, mcpToken) - var list []map[string]any - for _, e := range entries { - if filter != "" && e.Type != filter { - continue - } - item := map[string]any{ - "entry_id": e.EntryID, - "title": e.Title, - "type": e.Type, - } - if e.VaultData != nil && len(e.VaultData.URLs) > 0 { - item["urls"] = e.VaultData.URLs - } - list = append(list, item) - } - result, _ := json.Marshal(list) - return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil - - case "get_totp": - query, _ := call.Arguments["query"].(string) - if query == "" { - return nil, fmt.Errorf("query is required") - } - entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query) - if err != nil || len(entries) == 0 { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil - } - entries = mcpFilterEntries(entries, mcpToken) - if len(entries) == 0 { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil - } - entry := entries[0] - if entry.VaultData == nil { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil - } - for _, field := range entry.VaultData.Fields { - if field.Kind == "totp" { - if field.L2 { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil - } - seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", "")) - code, err := totp.GenerateCode(seed, time.Now()) - if err != nil { - return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil - } - now := time.Now().Unix() - expiresIn := 30 - (now % 30) - result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn) - return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil - } - } - return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil - - case "check_expiring": - daysF, _ := call.Arguments["days"].(float64) - days := int(daysF) - if days <= 0 { - days = 30 - } - entries, err := lib.EntryList(h.db(r), h.vk(r), nil) - if err != nil { - return nil, err - } - entries = mcpFilterEntries(entries, mcpToken) - cutoff := time.Now().AddDate(0, 0, days) - var expiring []map[string]any - for _, e := range entries { - if e.VaultData == nil || e.VaultData.Expires == "" { - continue - } - exp, err := time.Parse("2006-01-02", e.VaultData.Expires) - if err != nil { - continue - } - if exp.Before(cutoff) { - daysRemaining := int(exp.Sub(time.Now()).Hours() / 24) - expiring = append(expiring, map[string]any{ - "title": e.Title, - "type": e.Type, - "expires": e.VaultData.Expires, - "days_remaining": daysRemaining, - }) - } - } - result, _ := json.Marshal(expiring) - return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil - - default: - return nil, fmt.Errorf("unknown tool: %s", call.Name) - } -} - -func mcpSuccess(w http.ResponseWriter, id any, result any) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": result, - }) -} - -func mcpError(w http.ResponseWriter, id any, code int, message string) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "jsonrpc": "2.0", - "id": id, - "error": map[string]any{ - "code": code, - "message": message, - }, - }) -} - -// --------------------------------------------------------------------------- -// MCP Token Management -// --------------------------------------------------------------------------- - -// HandleCreateMCPToken creates a scoped MCP token. -func (h *Handlers) HandleCreateMCPToken(w http.ResponseWriter, r *http.Request) { - var req struct { - Label string `json:"label"` - EntryIDs []lib.HexID `json:"entry_ids"` - ReadOnly bool `json:"read_only"` - ExpiresIn int64 `json:"expires_in_days"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") - return - } - if req.Label == "" { - ErrorResponse(w, http.StatusBadRequest, "missing_label", "Label is required") - return - } - - t := &lib.MCPToken{ - Label: req.Label, - EntryIDs: req.EntryIDs, - ReadOnly: req.ReadOnly, - } - if req.ExpiresIn > 0 { - t.ExpiresAt = time.Now().Unix() + req.ExpiresIn*86400 - } - - if err := lib.CreateMCPToken(h.db(r), t); err != nil { - ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create MCP token") - return - } - - JSONResponse(w, http.StatusCreated, t) -} - -// HandleListMCPTokens returns all MCP tokens. -func (h *Handlers) HandleListMCPTokens(w http.ResponseWriter, r *http.Request) { - tokens, err := lib.ListMCPTokens(h.db(r)) - if err != nil { - ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list MCP tokens") - return - } - if tokens == nil { - tokens = []lib.MCPToken{} - } - JSONResponse(w, http.StatusOK, tokens) -} - -// HandleDeleteMCPToken revokes an MCP token. -func (h *Handlers) HandleDeleteMCPToken(w http.ResponseWriter, r *http.Request) { - id, err := lib.HexToID(chi.URLParam(r, "id")) - if err != nil { - ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid token ID") - return - } - if err := lib.DeleteMCPToken(h.db(r), id); err != nil { - if err == lib.ErrNotFound { - ErrorResponse(w, http.StatusNotFound, "not_found", "Token not found") - return - } - ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete MCP token") - return - } - JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) -} - // --------------------------------------------------------------------------- // WebAuthn PRF // --------------------------------------------------------------------------- @@ -1596,26 +1247,6 @@ func (h *Handlers) HandleDeleteWebAuthnCredential(w http.ResponseWriter, r *http // Helpers // --------------------------------------------------------------------------- -// mcpFilterEntries filters entries based on scoped MCP token restrictions. -func mcpFilterEntries(entries []lib.Entry, token *lib.MCPToken) []lib.Entry { - if token == nil || len(token.EntryIDs) == 0 { - return entries - } - - idSet := map[lib.HexID]bool{} - for _, id := range token.EntryIDs { - idSet[id] = true - } - - var filtered []lib.Entry - for _, e := range entries { - if idSet[e.EntryID] { - filtered = append(filtered, e) - } - } - return filtered -} - func stripL2Fields(vd *lib.VaultData) { for i := range vd.Fields { if vd.Fields[i].L2 { @@ -1678,98 +1309,6 @@ func generateTOTPSecret() string { return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) } -// GeoLookup returns the visitor's geolocation. -// If ?lat=X&lon=Y supplied (browser geo fallback), reverse-geocodes those coordinates via BigDataCloud. -// Otherwise geolocates the request IP; returns {"private":true} for LAN IPs. -func (h *Handlers) GeoLookup(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - // lat/lon path — browser geolocation fallback for LAN visitors - if latStr := r.URL.Query().Get("lat"); latStr != "" { - lonStr := r.URL.Query().Get("lon") - latF, errLat := strconv.ParseFloat(latStr, 64) - lonF, errLon := strconv.ParseFloat(lonStr, 64) - if errLat != nil || errLon != nil { - json.NewEncoder(w).Encode(map[string]any{"private": true}) - return - } - // Nominatim reverse geocode (OSM, free, no key) - url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=%f&lon=%f", latF, lonF) - client := &http.Client{Timeout: 5 * time.Second} - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("User-Agent", "clavitor/1.0 (https://clavitor.com)") - resp, err := client.Do(req) - if err == nil { - defer resp.Body.Close() - var raw struct { - Address struct { - City string `json:"city"` - Town string `json:"town"` - Village string `json:"village"` - State string `json:"state"` - Country string `json:"country"` - CountryCode string `json:"country_code"` - } `json:"address"` - } - if json.NewDecoder(resp.Body).Decode(&raw) == nil { - city := raw.Address.City - if city == "" { city = raw.Address.Town } - if city == "" { city = raw.Address.Village } - cc := strings.ToUpper(raw.Address.CountryCode) - json.NewEncoder(w).Encode(map[string]any{ - "city": city, - "region": raw.Address.State, - "country_name": raw.Address.Country, - "country_code": cc, - "latitude": latF, - "longitude": lonF, - }) - return - } - } - // reverse-geocode failed — at least return coords - json.NewEncoder(w).Encode(map[string]any{"latitude": latF, "longitude": lonF}) - return - } - - // IP-based path - ip := realIP(r) - if isPrivateIP(ip) { - json.NewEncoder(w).Encode(map[string]any{"private": true}) - return - } - resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=status,city,regionName,country,countryCode,lat,lon") - if err != nil { - json.NewEncoder(w).Encode(map[string]any{"private": true}) - return - } - defer resp.Body.Close() - var raw map[string]any - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || raw["status"] != "success" { - json.NewEncoder(w).Encode(map[string]any{"private": true}) - return - } - json.NewEncoder(w).Encode(map[string]any{ - "city": raw["city"], - "region": raw["regionName"], - "country_name": raw["country"], - "country_code": raw["countryCode"], - "latitude": raw["lat"], - "longitude": raw["lon"], - }) -} - -func isPrivateIP(ip string) bool { - parsed := net.ParseIP(ip) - if parsed == nil { return true } - private := []string{"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","::1/128","fc00::/7"} - for _, cidr := range private { - _, block, _ := net.ParseCIDR(cidr) - if block != nil && block.Contains(parsed) { return true } - } - return false -} - // --- Backup / Restore --- // ListBackups returns all available backup files. diff --git a/clavis/clavis-vault/api/integration_test.go b/clavis/clavis-vault/api/integration_test.go index d44f133..9ae3e9e 100644 --- a/clavis/clavis-vault/api/integration_test.go +++ b/clavis/clavis-vault/api/integration_test.go @@ -1,35 +1,40 @@ package api -// Integration tests — white-box (same package) so we can call NewRouter directly. -// Tests the three core user journeys: -// 1. Create login → agents and humans store credentials -// 2. Read login → round-trip encrypt/decrypt verification -// 3. Use login → URL match, TOTP generation, MCP tool calls +// Integration tests for the Clavitor vault API. // -// Uses inou.com as the test credential — a real login flow. +// The test client authenticates exactly as production does: +// - 8-byte L1 key sent as base64url Bearer on every request +// - DB filename derived from L1[:4]: clavitor-{base64url(l1[:4])} +// - L1 normalized to 16 bytes for AES-128 vault encryption +// +// Each test gets an isolated vault (temp dir + fresh DB). // Run: go test ./api/... -v import ( "bytes" + "embed" + "encoding/base64" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" - "embed" "github.com/johanj/clavitor/lib" ) -// --- test helpers --- +// --- test client --- type tc struct { - srv *httptest.Server - token string - t *testing.T + srv *httptest.Server + bearer string // base64url-encoded L1 key (8 bytes) + t *testing.T } +// newTestClient creates an isolated vault and test server. +// The L1 key is deterministic so tests are reproducible. func newTestClient(t *testing.T) *tc { t.Helper() @@ -40,33 +45,31 @@ func newTestClient(t *testing.T) *tc { SessionTTL: 86400, } - // Create a DB with a fake credential so VaultMiddleware can derive vault key - fakePubKey := make([]byte, 65) - for i := range fakePubKey { fakePubKey[i] = byte(i + 1) } - dbPath := tmpDir + "/01020304.db" + // Fixed 8-byte L1 key for testing. + l1Raw := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0x11, 0x22, 0x33, 0x44} + bearer := base64.RawURLEncoding.EncodeToString(l1Raw) + + // DB filename must match what L1Middleware derives: clavitor-{base64url(l1[:4])} + prefix := base64.RawURLEncoding.EncodeToString(l1Raw[:4]) + dbPath := tmpDir + "/clavitor-" + prefix + db, err := lib.OpenDB(dbPath) - if err != nil { t.Fatalf("opendb: %v", err) } - if err := lib.MigrateDB(db); err != nil { t.Fatalf("migrate: %v", err) } - // Store fake credential for vault key derivation - lib.StoreWebAuthnCredential(db, &lib.WebAuthnCredential{ - CredID: lib.HexID(1), Name: "test", PublicKey: fakePubKey, - CredentialID: []byte("test-raw-id"), - }) + if err != nil { + t.Fatalf("opendb: %v", err) + } + if err := lib.MigrateDB(db); err != nil { + t.Fatalf("migrate: %v", err) + } db.Close() var emptyFS embed.FS - srv := httptest.NewServer(NewRouter(cfg, emptyFS, emptyFS)) + srv := httptest.NewServer(NewRouter(cfg, emptyFS)) t.Cleanup(srv.Close) - // Auth - resp, _ := srv.Client().Post(srv.URL+"/api/auth/setup", "application/json", nil) - var setup struct{ Token string `json:"token"` } - json.NewDecoder(resp.Body).Decode(&setup) - resp.Body.Close() - - return &tc{srv: srv, token: setup.Token, t: t} + return &tc{srv: srv, bearer: bearer, t: t} } +// req sends an authenticated HTTP request. func (c *tc) req(method, path string, body any) *http.Response { c.t.Helper() var r io.Reader @@ -75,13 +78,37 @@ func (c *tc) req(method, path string, body any) *http.Response { r = bytes.NewReader(b) } req, _ := http.NewRequest(method, c.srv.URL+path, r) - if body != nil { req.Header.Set("Content-Type", "application/json") } - if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+c.bearer) resp, err := c.srv.Client().Do(req) - if err != nil { c.t.Fatalf("req %s %s: %v", method, path, err) } + if err != nil { + c.t.Fatalf("req %s %s: %v", method, path, err) + } return resp } +// reqNoAuth sends an unauthenticated request. +func (c *tc) reqNoAuth(method, path string, body any) *http.Response { + c.t.Helper() + var r io.Reader + if body != nil { + b, _ := json.Marshal(body) + r = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, c.srv.URL+path, r) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.srv.Client().Do(req) + if err != nil { + c.t.Fatalf("req %s %s: %v", method, path, err) + } + return resp +} + +// must asserts status code and returns parsed JSON object. func (c *tc) must(resp *http.Response, wantStatus int) map[string]any { c.t.Helper() defer resp.Body.Close() @@ -94,109 +121,250 @@ func (c *tc) must(resp *http.Response, wantStatus int) map[string]any { return out } -func inouEntry() map[string]any { +// mustList asserts status code and returns parsed JSON array. +func (c *tc) mustList(resp *http.Response, wantStatus int) []map[string]any { + c.t.Helper() + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != wantStatus { + c.t.Fatalf("expected %d, got %d: %s", wantStatus, resp.StatusCode, body) + } + var out []map[string]any + json.Unmarshal(body, &out) + return out +} + +// --- test data --- + +func credentialEntry(title, username, password string, urls []string) map[string]any { return map[string]any{ - "title": "inou.com", + "title": title, "type": "credential", "data": map[string]any{ - "title": "inou.com", + "title": title, "type": "credential", "fields": []map[string]any{ - {"label": "username", "value": "test@inou.com", "kind": "text"}, - {"label": "password", "value": "TestPass!InouDev42", "kind": "password"}, - {"label": "api_key", "value": "inou_sk_test_abc123", "kind": "text"}, + {"label": "username", "value": username, "kind": "text"}, + {"label": "password", "value": password, "kind": "password"}, }, - "urls": []string{"https://inou.com", "https://app.inou.com"}, + "urls": urls, }, } } -// --- Tests --- +// --------------------------------------------------------------------------- +// Health & Ping +// --------------------------------------------------------------------------- func TestHealth(t *testing.T) { c := newTestClient(t) - result := c.must(c.req("GET", "/health", nil), 200) + result := c.must(c.reqNoAuth("GET", "/health", nil), 200) if result["status"] != "ok" { - t.Errorf("health status = %v", result["status"]) + t.Errorf("status = %v, want ok", result["status"]) } } -func TestCreateLogin(t *testing.T) { +func TestPing(t *testing.T) { c := newTestClient(t) - result := c.must(c.req("POST", "/api/entries", inouEntry()), 201) - id, _ := result["entry_id"].(string) - if id == "" { - t.Fatal("create returned no entry_id") + result := c.must(c.reqNoAuth("GET", "/ping", nil), 200) + if result["ok"] != true { + t.Errorf("ok = %v, want true", result["ok"]) + } + if result["node"] == nil || result["node"] == "" { + t.Error("node should not be empty") + } + if result["ts"] == nil { + t.Error("ts should be present") } - t.Logf("Created entry: %s", id) } -func TestReadLogin_RoundTrip(t *testing.T) { +// --------------------------------------------------------------------------- +// L1 Auth +// --------------------------------------------------------------------------- + +func TestL1Auth_valid_key(t *testing.T) { + c := newTestClient(t) + // Should return empty array, not an auth error + c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200) +} + +func TestL1Auth_bad_bearer_rejected(t *testing.T) { + c := newTestClient(t) + req, _ := http.NewRequest("GET", c.srv.URL+"/api/entries", nil) + req.Header.Set("Authorization", "Bearer not-valid-base64") + resp, _ := c.srv.Client().Do(req) + defer resp.Body.Close() + if resp.StatusCode != 401 { + t.Errorf("bad bearer should return 401, got %d", resp.StatusCode) + } +} + +func TestL1Auth_wrong_key_vault_not_found(t *testing.T) { + c := newTestClient(t) + // Valid base64url but points to a non-existent vault + wrongL1 := base64.RawURLEncoding.EncodeToString([]byte{0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8}) + req, _ := http.NewRequest("GET", c.srv.URL+"/api/entries", nil) + req.Header.Set("Authorization", "Bearer "+wrongL1) + resp, _ := c.srv.Client().Do(req) + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("wrong L1 key should return 404 (vault not found), got %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Entry CRUD +// --------------------------------------------------------------------------- + +func TestCreateEntry(t *testing.T) { + c := newTestClient(t) + result := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "hunter2", []string{"https://github.com"})), 201) + if result["entry_id"] == nil || result["entry_id"] == "" { + t.Fatal("create should return entry_id") + } +} + +func TestCreateEntry_missing_title(t *testing.T) { + c := newTestClient(t) + resp := c.req("POST", "/api/entries", map[string]any{"type": "credential"}) + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Errorf("missing title should return 400, got %d", resp.StatusCode) + } +} + +func TestReadEntry_roundtrip(t *testing.T) { c := newTestClient(t) - // Create - created := c.must(c.req("POST", "/api/entries", inouEntry()), 201) + created := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "hunter2", nil)), 201) id := created["entry_id"].(string) - // Read back - result := c.must(c.req("GET", "/api/entries/"+id, nil), 200) + got := c.must(c.req("GET", "/api/entries/"+id, nil), 200) + data := got["data"].(map[string]any) + fields := data["fields"].([]any) - data, _ := result["data"].(map[string]any) - if data == nil { t.Fatal("read returned no data") } - - if data["title"] != "inou.com" { - t.Errorf("title = %v", data["title"]) - } - - // Verify password survived encrypt/decrypt - fields, _ := data["fields"].([]any) - var pw string + found := map[string]string{} for _, f := range fields { fm := f.(map[string]any) - if fm["label"] == "password" { - pw, _ = fm["value"].(string) + found[fm["label"].(string)] = fm["value"].(string) + } + if found["username"] != "octocat" { + t.Errorf("username = %q, want octocat", found["username"]) + } + if found["password"] != "hunter2" { + t.Errorf("password = %q, want hunter2", found["password"]) + } +} + +func TestUpdateEntry(t *testing.T) { + c := newTestClient(t) + + created := c.must(c.req("POST", "/api/entries", credentialEntry("Old", "user", "pass", nil)), 201) + id := created["entry_id"].(string) + + updated := c.must(c.req("PUT", "/api/entries/"+id, map[string]any{ + "title": "New", + "version": 1, + "data": map[string]any{ + "title": "New", "type": "credential", + "fields": []map[string]any{{"label": "username", "value": "newuser", "kind": "text"}}, + }, + }), 200) + + if updated["title"] != "New" { + t.Errorf("title = %v, want New", updated["title"]) + } +} + +func TestUpdateEntry_version_conflict(t *testing.T) { + c := newTestClient(t) + + created := c.must(c.req("POST", "/api/entries", credentialEntry("Test", "u", "p", nil)), 201) + id := created["entry_id"].(string) + + c.must(c.req("PUT", "/api/entries/"+id, map[string]any{ + "title": "V2", "version": 1, + "data": map[string]any{"title": "V2", "type": "credential"}, + }), 200) + + resp := c.req("PUT", "/api/entries/"+id, map[string]any{ + "title": "Stale", "version": 1, + "data": map[string]any{"title": "Stale", "type": "credential"}, + }) + defer resp.Body.Close() + if resp.StatusCode != 409 { + t.Errorf("stale version should return 409, got %d", resp.StatusCode) + } +} + +func TestDeleteEntry(t *testing.T) { + c := newTestClient(t) + + created := c.must(c.req("POST", "/api/entries", credentialEntry("ToDelete", "u", "p", nil)), 201) + id := created["entry_id"].(string) + + c.must(c.req("DELETE", "/api/entries/"+id, nil), 200) + + entries := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200) + for _, e := range entries { + if e["entry_id"] == id { + t.Error("deleted entry should not appear in list") } } - if pw != "TestPass!InouDev42" { - t.Errorf("password round-trip failed: got %q", pw) - } - t.Logf("Round-trip OK — password decrypted correctly") } -func TestURLMatch(t *testing.T) { +func TestListEntries_meta(t *testing.T) { c := newTestClient(t) - c.must(c.req("POST", "/api/entries", inouEntry()), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("One", "u", "p", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("Two", "u", "p", nil)), 201) - // Extension calls this when you land on app.inou.com/login - c.must(c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil), 200) + entries := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200) + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } + for _, e := range entries { + if e["data"] != nil { + t.Error("meta mode should not include field data") + } + } +} - // result is an array - resp := c.req("GET", "/api/ext/match?url=https://app.inou.com/login", nil) +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +func TestSearch(t *testing.T) { + c := newTestClient(t) + c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("GitLab", "u", "p", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("AWS", "u", "p", nil)), 201) + + entries := c.mustList(c.req("GET", "/api/search?q=Git", nil), 200) + if len(entries) != 2 { + t.Errorf("search for 'Git' should return 2, got %d", len(entries)) + } +} + +func TestSearch_no_query(t *testing.T) { + c := newTestClient(t) + resp := c.req("GET", "/api/search", nil) defer resp.Body.Close() - var matches []map[string]any - json.NewDecoder(resp.Body).Decode(&matches) - - if len(matches) == 0 { - t.Error("no URL match for app.inou.com — expected inou.com entry to match") + if resp.StatusCode != 400 { + t.Errorf("missing query should return 400, got %d", resp.StatusCode) } - t.Logf("URL match: %d entries for app.inou.com", len(matches)) } -func TestTOTP_AgentGeneratesCode(t *testing.T) { - c := newTestClient(t) +// --------------------------------------------------------------------------- +// TOTP +// --------------------------------------------------------------------------- +func TestTOTP_valid_code(t *testing.T) { + c := newTestClient(t) entry := map[string]any{ - "title": "inou.com", - "type": "credential", + "title": "2FA Test", "type": "credential", "data": map[string]any{ - "title": "inou.com", - "type": "credential", - "fields": []map[string]any{ - {"label": "username", "value": "test@inou.com", "kind": "text"}, - {"label": "password", "value": "pass", "kind": "password"}, - {"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp", "l2": false}, - }, - "urls": []string{"https://inou.com"}, + "title": "2FA Test", "type": "credential", + "fields": []map[string]any{{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"}}, }, } created := c.must(c.req("POST", "/api/entries", entry), 201) @@ -209,270 +377,226 @@ func TestTOTP_AgentGeneratesCode(t *testing.T) { } expiresIn, _ := result["expires_in"].(float64) if expiresIn <= 0 || expiresIn > 30 { - t.Errorf("expires_in = %v", expiresIn) + t.Errorf("expires_in = %v, want 1-30", expiresIn) } - t.Logf("TOTP: %s (expires in %.0fs) — agent can complete 2FA without human", code, expiresIn) } -func TestMCP_ListCredentials(t *testing.T) { +func TestTOTP_L2_returns_locked(t *testing.T) { c := newTestClient(t) - - for _, title := range []string{"inou.com", "GitHub", "AWS"} { - c.req("POST", "/api/entries", map[string]any{ - "title": title, "type": "credential", - "data": map[string]any{ - "title": title, "type": "credential", - "fields": []map[string]any{{"label": "password", "value": "x", "kind": "password"}}, - }, - }) - } - - mcp := map[string]any{ - "jsonrpc": "2.0", "id": 1, - "method": "tools/call", - "params": map[string]any{"name": "list_credentials", "arguments": map[string]any{}}, - } - result := c.must(c.req("POST", "/mcp", mcp), 200) - - res, _ := result["result"].(map[string]any) - if res == nil { - t.Fatalf("MCP error: %v", result["error"]) - } - content, _ := res["content"].([]any) - if len(content) == 0 { - t.Fatal("MCP list_credentials returned empty content") - } - text := content[0].(map[string]any)["text"].(string) - t.Logf("MCP list_credentials: %s", text[:min(len(text), 120)]) -} - -func TestMCP_GetCredential_Inou(t *testing.T) { - c := newTestClient(t) - c.must(c.req("POST", "/api/entries", inouEntry()), 201) - - mcp := map[string]any{ - "jsonrpc": "2.0", "id": 2, - "method": "tools/call", - "params": map[string]any{"name": "get_credential", "arguments": map[string]any{"query": "inou.com"}}, - } - result := c.must(c.req("POST", "/mcp", mcp), 200) - - res, _ := result["result"].(map[string]any) - if res == nil { t.Fatalf("MCP error: %v", result["error"]) } - content, _ := res["content"].([]any) - if len(content) == 0 { t.Fatal("empty content") } - - text := content[0].(map[string]any)["text"].(string) - if !strings.Contains(text, "inou_sk_test_abc123") { - t.Errorf("API key missing from MCP response: %s", text) - } - t.Logf("MCP get_credential: agent retrieved inou.com API key") -} - -func TestMCP_GetTOTP(t *testing.T) { - c := newTestClient(t) - entry := map[string]any{ - "title": "inou.com", - "type": "credential", + "title": "L2 TOTP", "type": "credential", "data": map[string]any{ - "title": "inou.com", - "type": "credential", - "fields": []map[string]any{ - {"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"}, - }, + "title": "L2 TOTP", "type": "credential", + "fields": []map[string]any{{"label": "totp", "value": "JBSWY3DPEHPK3PXP", "kind": "totp", "l2": true}}, }, } - c.must(c.req("POST", "/api/entries", entry), 201) + created := c.must(c.req("POST", "/api/entries", entry), 201) + id := created["entry_id"].(string) - mcp := map[string]any{ - "jsonrpc": "2.0", "id": 3, - "method": "tools/call", - "params": map[string]any{"name": "get_totp", "arguments": map[string]any{"query": "inou.com"}}, + result := c.must(c.req("GET", "/api/ext/totp/"+id, nil), 200) + if result["l2"] != true { + t.Error("L2 TOTP should return l2:true") } - result := c.must(c.req("POST", "/mcp", mcp), 200) - res, _ := result["result"].(map[string]any) - if res == nil { t.Fatalf("MCP error: %v", result["error"]) } - content, _ := res["content"].([]any) - text := content[0].(map[string]any)["text"].(string) - - // MCP get_totp returns JSON: {"code":"XXXXXX","expires_in":N} - var totpJSON struct { - Code string `json:"code"` - ExpiresIn int `json:"expires_in"` - } - if err := json.Unmarshal([]byte(text), &totpJSON); err != nil { - t.Fatalf("get_totp response not valid JSON: %s", text) - } - if len(totpJSON.Code) != 6 { - t.Errorf("TOTP code = %q, want 6 digits", totpJSON.Code) - } - if totpJSON.ExpiresIn <= 0 || totpJSON.ExpiresIn > 30 { - t.Errorf("expires_in = %d", totpJSON.ExpiresIn) - } - t.Logf("MCP get_totp: %s (expires in %ds)", totpJSON.Code, totpJSON.ExpiresIn) } -func TestScopedToken_HidesOtherEntries(t *testing.T) { +// --------------------------------------------------------------------------- +// URL Match +// --------------------------------------------------------------------------- + +func TestURLMatch(t *testing.T) { + c := newTestClient(t) + c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", []string{"https://github.com"})), 201) + + matches := c.mustList(c.req("GET", "/api/ext/match?url=https://github.com/login", nil), 200) + if len(matches) == 0 { + t.Error("should match github.com for github.com/login") + } +} + +func TestURLMatch_no_match(t *testing.T) { + c := newTestClient(t) + c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "u", "p", []string{"https://github.com"})), 201) + + matches := c.mustList(c.req("GET", "/api/ext/match?url=https://example.com", nil), 200) + if len(matches) != 0 { + t.Errorf("should not match, got %d", len(matches)) + } +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +func TestImport_ChromeCSV(t *testing.T) { c := newTestClient(t) - // Two entries — we'll scope the token to only one of them - inouResult := c.must(c.req("POST", "/api/entries", map[string]any{ - "title": "inou.com", "type": "credential", - "data": map[string]any{ - "title": "inou.com", "type": "credential", - "fields": []map[string]any{{"label": "key", "value": "inou_key", "kind": "text"}}, - }, - }), 201) - inouID, _ := inouResult["entry_id"].(string) + csv := "name,url,username,password\nGitHub,https://github.com,octocat,hunter2\n" + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + part, _ := w.CreateFormFile("file", "passwords.csv") + part.Write([]byte(csv)) + w.Close() - c.must(c.req("POST", "/api/entries", map[string]any{ - "title": "GitHub", "type": "credential", - "data": map[string]any{ - "title": "GitHub", "type": "credential", - "fields": []map[string]any{{"label": "token", "value": "ghp_secret", "kind": "password"}}, - }, - }), 201) - - // Scoped token: inou entry only - tokenResult := c.must(c.req("POST", "/api/mcp-tokens", map[string]any{ - "label": "inou-agent", "entry_ids": []string{inouID}, "read_only": true, - }), 201) - scopedToken, _ := tokenResult["token"].(string) - - // Scoped tokens are MCP-only — test via MCP list_credentials - scoped := &tc{srv: c.srv, token: scopedToken, t: t} - mcp := map[string]any{ - "jsonrpc": "2.0", "id": 99, - "method": "tools/call", - "params": map[string]any{"name": "list_credentials", "arguments": map[string]any{"search": ""}}, + req, _ := http.NewRequest("POST", c.srv.URL+"/api/import", body) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+c.bearer) + resp, err := c.srv.Client().Do(req) + if err != nil { + t.Fatal(err) } - result := scoped.must(scoped.req("POST", "/mcp", mcp), 200) - res, _ := result["result"].(map[string]any) - if res == nil { t.Fatalf("MCP error: %v", result["error"]) } - contentList, _ := res["content"].([]any) - text := "" - if len(contentList) > 0 { - text, _ = contentList[0].(map[string]any)["text"].(string) + defer resp.Body.Close() + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("import returned %d: %s", resp.StatusCode, b) } - if strings.Contains(text, "GitHub") { - t.Errorf("scoped token leaked GitHub entry: %s", text) - } - t.Logf("Scoped token OK: only scoped entries visible") } +func TestImport_unknown_format_rejected(t *testing.T) { + c := newTestClient(t) + + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + part, _ := w.CreateFormFile("file", "garbage.txt") + part.Write([]byte("this is not a password export")) + w.Close() + + req, _ := http.NewRequest("POST", c.srv.URL+"/api/import", body) + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+c.bearer) + resp, err := c.srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Errorf("unknown format should return 400, got %d", resp.StatusCode) + } +} + +// --------------------------------------------------------------------------- +// Password Generator +// --------------------------------------------------------------------------- + func TestPasswordGenerator(t *testing.T) { c := newTestClient(t) - result := c.must(c.req("GET", "/api/generate?length=24&type=random", nil), 200) + result := c.must(c.req("GET", "/api/generate?length=24", nil), 200) pw, _ := result["password"].(string) - if len(pw) < 16 { - t.Errorf("password too short: %q", pw) + if len(pw) != 24 { + t.Errorf("password length = %d, want 24", len(pw)) } - t.Logf("Generated: %s", pw) } +func TestPasswordGenerator_passphrase(t *testing.T) { + c := newTestClient(t) + result := c.must(c.req("GET", "/api/generate?words=4", nil), 200) + pw, _ := result["password"].(string) + words := strings.Split(pw, "-") + if len(words) != 4 { + t.Errorf("passphrase should have 4 words, got %d: %q", len(words), pw) + } +} + +// --------------------------------------------------------------------------- +// Audit Log +// --------------------------------------------------------------------------- + func TestAuditLog(t *testing.T) { c := newTestClient(t) - created := c.must(c.req("POST", "/api/entries", inouEntry()), 201) + + created := c.must(c.req("POST", "/api/entries", credentialEntry("Audited", "u", "p", nil)), 201) id := created["entry_id"].(string) - c.req("GET", "/api/entries/"+id, nil) + c.must(c.req("GET", "/api/entries/"+id, nil), 200) - // Audit log returns a plain JSON array - auditResp := c.req("GET", "/api/audit", nil) - if auditResp.StatusCode != 200 { - t.Fatalf("audit returned %d", auditResp.StatusCode) + events := c.mustList(c.req("GET", "/api/audit", nil), 200) + if len(events) < 2 { + t.Errorf("expected at least 2 events (create + read), got %d", len(events)) } - defer auditResp.Body.Close() - var events []map[string]any - json.NewDecoder(auditResp.Body).Decode(&events) - if len(events) == 0 { - t.Error("audit log empty after create + read") + actions := map[string]bool{} + for _, e := range events { + if a, ok := e["action"].(string); ok { + actions[a] = true + } + } + if !actions["create"] { + t.Error("missing 'create' in audit log") + } + if !actions["read"] { + t.Error("missing 'read' in audit log") } - t.Logf("Audit log: %d events", len(events)) } -func min(a, b int) int { if a < b { return a }; return b } +// --------------------------------------------------------------------------- +// WebAuthn Auth Flow +// --------------------------------------------------------------------------- -// --- WebAuthn Auth Flow Tests --- - -func TestAuthStatus_FreshVault(t *testing.T) { +func TestAuthStatus_fresh(t *testing.T) { c := newTestClient(t) - resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status") - defer resp.Body.Close() - var result map[string]any - json.NewDecoder(resp.Body).Decode(&result) - + result := c.must(c.reqNoAuth("GET", "/api/auth/status", nil), 200) if result["state"] != "fresh" { - t.Errorf("expected fresh state, got %v", result["state"]) + t.Errorf("state = %v, want fresh", result["state"]) } - if result["credentials"].(float64) != 0 { - t.Errorf("expected 0 credentials, got %v", result["credentials"]) - } - t.Logf("Auth status: %v", result) } -func TestAuthStatus_LockedVault(t *testing.T) { +func TestAuthRegisterBegin_fresh(t *testing.T) { c := newTestClient(t) - // Register a credential via authenticated endpoint - c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{ - "public_key": []byte{1, 2, 3}, - "prf_salt": []byte{4, 5, 6}, - "name": "Test Key", - }), 201) - - // Check status (unauthenticated) - resp, _ := c.srv.Client().Get(c.srv.URL + "/api/auth/status") - defer resp.Body.Close() - var result map[string]any - json.NewDecoder(resp.Body).Decode(&result) - - if result["state"] != "locked" { - t.Errorf("expected locked state, got %v", result["state"]) - } - t.Logf("Auth status after registration: %v", result) -} - -func TestAuthRegister_BlocksWhenCredentialsExist(t *testing.T) { - c := newTestClient(t) - // Register a credential - c.must(c.req("POST", "/api/webauthn/register/complete", map[string]any{ - "public_key": []byte{1, 2, 3}, - "prf_salt": []byte{4, 5, 6}, - "name": "Test Key", - }), 201) - - // Try unauthenticated registration (should be blocked) - resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", nil) - defer resp.Body.Close() - if resp.StatusCode != http.StatusForbidden { - t.Errorf("expected 403, got %d", resp.StatusCode) - } - t.Log("Unauthenticated register correctly blocked when credentials exist") -} - -func TestSetup_CreatesSession(t *testing.T) { - c := newTestClient(t) - // Setup should create a session - resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/setup", "application/json", nil) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("expected 200, got %d", resp.StatusCode) - } - t.Log("Setup creates session correctly") -} - -func TestAuthRegisterBegin_FreshVault(t *testing.T) { - c := newTestClient(t) - resp, _ := c.srv.Client().Post(c.srv.URL+"/api/auth/register/begin", "application/json", strings.NewReader("{}")) + resp := c.reqNoAuth("POST", "/api/auth/register/begin", map[string]any{}) defer resp.Body.Close() if resp.StatusCode != 200 { - t.Fatalf("expected 200, got %d", resp.StatusCode) + b, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, b) } - var options map[string]any - json.NewDecoder(resp.Body).Decode(&options) - pk := options["publicKey"].(map[string]any) + var result map[string]any + json.NewDecoder(resp.Body).Decode(&result) + pk := result["publicKey"].(map[string]any) if pk["challenge"] == nil { - t.Fatal("no challenge in response") + t.Fatal("response should contain a challenge") + } +} + +// --------------------------------------------------------------------------- +// Tier Isolation +// --------------------------------------------------------------------------- + +// TestTierIsolation verifies that L2/L3 encrypted blobs survive the L1 +// envelope encrypt/decrypt roundtrip intact. The server packs all fields +// into a single AES-GCM envelope (L1). L2/L3 field values are opaque +// ciphertext — the server stores them, never inspects them. +func TestTierIsolation(t *testing.T) { + c := newTestClient(t) + + l2Blob := "AQIDBAUGB5iL2EncryptedBlob+test==" + l3Blob := "AQIDBAUGB5iL3EncryptedBlob+test==" + + created := c.must(c.req("POST", "/api/entries", map[string]any{ + "type": "credential", "title": "TierTest", + "data": map[string]any{ + "title": "TierTest", "type": "credential", + "fields": []map[string]any{ + {"label": "Username", "value": "testuser", "kind": "text"}, + {"label": "Password", "value": l2Blob, "kind": "password", "tier": 2}, + {"label": "SSN", "value": l3Blob, "kind": "text", "tier": 3, "l2": true}, + }, + }, + }), 201) + id := created["entry_id"].(string) + + got := c.must(c.req("GET", "/api/entries/"+id, nil), 200) + data := got["data"].(map[string]any) + fields := data["fields"].([]any) + + found := map[string]string{} + for _, raw := range fields { + f := raw.(map[string]any) + found[f["label"].(string)], _ = f["value"].(string) + } + + if found["Username"] != "testuser" { + t.Errorf("L1 Username = %q, want testuser", found["Username"]) + } + if found["Password"] != l2Blob { + t.Errorf("L2 Password blob changed: %q", found["Password"]) + } + if found["SSN"] != l3Blob { + t.Errorf("L3 SSN blob changed: %q", found["SSN"]) } - t.Logf("Register begin: challenge generated, rp=%v", pk["rp"]) } diff --git a/clavis/clavis-vault/api/middleware.go b/clavis/clavis-vault/api/middleware.go index 1a64321..0228cad 100644 --- a/clavis/clavis-vault/api/middleware.go +++ b/clavis/clavis-vault/api/middleware.go @@ -37,7 +37,6 @@ type contextKey string const ( ctxActor contextKey = "actor" ctxSession contextKey = "session" - ctxMCPToken contextKey = "mcp_token" ctxAgent contextKey = "agent" ctxDB contextKey = "db" ctxVaultKey contextKey = "vault_key" @@ -59,12 +58,6 @@ func SessionFromContext(ctx context.Context) *lib.Session { return v } -// MCPTokenFromContext returns the MCP token from request context (nil if normal session). -func MCPTokenFromContext(ctx context.Context) *lib.MCPToken { - v, _ := ctx.Value(ctxMCPToken).(*lib.MCPToken) - return v -} - // AgentFromContext returns the agent from request context (nil if not an agent request). func AgentFromContext(ctx context.Context) *lib.Agent { v, _ := ctx.Value(ctxAgent).(*lib.Agent) diff --git a/clavis/clavis-vault/api/routes.go b/clavis/clavis-vault/api/routes.go index 1808fb7..60923f5 100644 --- a/clavis/clavis-vault/api/routes.go +++ b/clavis/clavis-vault/api/routes.go @@ -2,35 +2,18 @@ package api import ( "embed" - "html/template" + "fmt" "io/fs" - "log" "net/http" + "os" + "time" "github.com/go-chi/chi/v5" "github.com/johanj/clavitor/lib" ) // NewRouter creates the main router with all routes registered. -func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux { - // Parse all website templates (nil if no templates embedded, e.g. tests) - tmpl, _ := template.ParseFS(templateFS, "templates/*.html") - - // Helper: render a marketing page - renderPage := func(w http.ResponseWriter, page, title, desc, activeNav string) { - if tmpl == nil { - http.Error(w, "templates not available", 500) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tmpl.ExecuteTemplate(w, "base.html", map[string]string{ - "Page": page, "Title": title, "Desc": desc, "ActiveNav": activeNav, - }); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", 500) - } - } - +func NewRouter(cfg *lib.Config, webFS embed.FS) *chi.Mux { r := chi.NewRouter() h := NewHandlers(cfg) @@ -45,10 +28,12 @@ func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux { r.Get("/health", h.Health) // Ping — minimal latency probe for looking glass (no DB, no auth) + node, _ := os.Hostname() r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Header().Set("Content-Length", "11") - w.Write([]byte(`{"ok":true}`)) + w.Header().Set("Access-Control-Allow-Origin", "*") + body := fmt.Sprintf(`{"ok":true,"node":"%s","ts":%d}`, node, time.Now().Unix()) + w.Write([]byte(body)) }) // Auth endpoints (unauthenticated — no Bearer, DB found by glob) @@ -66,120 +51,20 @@ func NewRouter(cfg *lib.Config, webFS embed.FS, templateFS embed.FS) *chi.Mux { mountAPIRoutes(r, h) }) - r.Get("/geo", h.GeoLookup) - // --- Vault App UI at /app/* --- - // Vault UI files (index.html, security.html, tokens.html) live in web/ appRoot, err := fs.Sub(webFS, "web") if err == nil { appServer := http.FileServer(http.FS(appRoot)) - // /app → /app/ r.Get("/app", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/app/", http.StatusMovedPermanently) }) - // /app/* → strip /app prefix and serve from web/ r.Handle("/app/*", http.StripPrefix("/app", appServer)) } - // --- Website static assets at root --- - serveEmbedded := func(path, contentType string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data, err := fs.ReadFile(webFS, "web/"+path) - if err != nil { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", contentType) - w.Write(data) - } - } - r.Get("/clavitor.css", serveEmbedded("clavitor.css", "text/css; charset=utf-8")) - r.Get("/worldmap.svg", serveEmbedded("worldmap.svg", "image/svg+xml")) - r.Get("/favicon.svg", serveEmbedded("favicon.svg", "image/svg+xml")) - - // --- Marketing Site --- - // Server-rendered Go templates — agent-friendly, no JS required. - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "landing", - "Clavitor — Password manager for humans with AI assistants", - "Password manager built for humans with AI assistants. Two-tier encryption keeps agents useful and secrets safe.", "") - }) - r.Get("/hosted", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "hosted", "Hosted — Clavitor", - "Your vault needs to work everywhere. 22 regions. $12/yr.", "hosted") - }) - r.Get("/pricing", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "pricing", "Pricing — Clavitor", - "No tiers. No per-seat. No surprises. Self-host free or hosted $12/yr.", "pricing") - }) - r.Get("/install", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "install", "Install — Clavitor", - "Self-host clavitor. One binary. No Docker. No Postgres.", "install") - }) - r.Get("/privacy", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "privacy", "Privacy Policy — Clavitor", - "No analytics. No tracking. No data sales.", "") - }) - r.Get("/terms", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "terms", "Terms of Service — Clavitor", "", "") - }) - r.Get("/sources", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "sources", "Sources — Clavitor", - "Real users. Real quotes. All verified.", "") - }) - // Integration guides (SEO pages) - r.Get("/integrations/claude-code", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "claude-code", - "Clavitor + Claude Code — Secure MCP credential access", - "Give Claude Code secure access to credentials and TOTP via MCP. Two-tier encryption keeps personal data sealed.", "integrations") - }) - r.Get("/integrations/codex", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "codex", - "Clavitor + OpenAI Codex — REST API and MCP integration", - "Connect Codex to your vault via REST API or MCP. Scoped tokens, TOTP generation, field-level encryption.", "integrations") - }) - r.Get("/integrations/openclaw", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "openclaw", - "Clavitor + OpenClaw — Multi-agent credential management", - "Give your OpenClaw agents scoped access to credentials. Two-tier encryption for autonomous agents.", "integrations") - }) - r.Get("/integrations/openclaw/cn", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "openclaw-cn", - "Clavitor + OpenClaw — AI 智能体凭据管理", - "为 OpenClaw 智能体提供安全凭据访问。两层加密,共享字段 AI 可读,个人字段仅生物识别解锁。", "integrations") - }) - - r.Get("/homepage2", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, "landing", - "Clavitor — Password manager for humans with AI assistants", - "Password manager built for humans with AI assistants. Two-tier encryption keeps agents useful and secrets safe.", "") - }) - - // SEO - r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("User-agent: *\nAllow: /\nSitemap: https://clavitor.com/sitemap.xml\n")) - }) - r.Get("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - pages := []string{ - "/", "/hosted", "/pricing", "/install", "/privacy", "/terms", - "/integrations/claude-code", "/integrations/codex", - "/integrations/openclaw", "/integrations/openclaw/cn", - } - xml := `` - for _, p := range pages { - xml += `https://clavitor.com` + p + `` - } - xml += `` - w.Write([]byte(xml)) - }) - return r } // mountAPIRoutes registers the authenticated API handlers on the given router. -// Used by both self-hosted (/api/*) and hosted (/{vault_id}/api/*) routes. func mountAPIRoutes(r chi.Router, h *Handlers) { // Vault info (for Tokens page config snippets) r.Get("/vault-info", h.VaultInfo) @@ -208,11 +93,6 @@ func mountAPIRoutes(r chi.Router, h *Handlers) { r.Get("/ext/totp/{id}", h.GetTOTP) r.Get("/ext/match", h.MatchURL) - // MCP Token management - r.Post("/mcp-tokens", h.HandleCreateMCPToken) - r.Get("/mcp-tokens", h.HandleListMCPTokens) - r.Delete("/mcp-tokens/{id}", h.HandleDeleteMCPToken) - // Backups r.Get("/backups", h.ListBackups) r.Post("/backups", h.CreateBackup) diff --git a/clavis/clavis-vault/api/tier_test.go b/clavis/clavis-vault/api/tier_test.go deleted file mode 100644 index 710be65..0000000 --- a/clavis/clavis-vault/api/tier_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package api - -import ( - "os" - "os/exec" - "strings" - "testing" - - "github.com/johanj/clavitor/lib" -) - -/* - * TestTierIsolation — verifies the three-tier encryption model end-to-end. - * - * Creates a test entry with fields at all three tiers: - * L1 (tier 1) — plaintext, server-readable - * L2 (tier 2) — encrypted blob, agent-decryptable - * L3 (tier 3) — encrypted blob, hardware-key-only - * - * Then verifies: - * 1. API path: L1 readable, L2 blob returned, L3 blob returned - * 2. DB path: L1 readable after envelope decrypt, L2 still ciphertext, L3 still ciphertext - * 3. Isolation: L2/L3 blobs in DB are NOT plaintext - */ -func TestTierIsolation(t *testing.T) { - c := newTestClient(t) - - // Fake encrypted blobs (in production, browser encrypts these with crypto.js) - l2Blob := "AQIDBAUGB5iL2EncryptedBlob+test==" - l3Blob := "AQIDBAUGB5iL3EncryptedBlob+test==" - - // Create entry with L1, L2, L3 fields - result := c.must(c.req("POST", "/api/entries", map[string]any{ - "type": "credential", - "title": "TierTest", - "data": map[string]any{ - "title": "TierTest", - "type": "credential", - "fields": []map[string]any{ - {"label": "Username", "value": "testuser", "kind": "text"}, - {"label": "Password", "value": l2Blob, "kind": "password", "tier": 2}, - {"label": "SSN", "value": l3Blob, "kind": "text", "tier": 3, "l2": true}, - }, - }, - }), 201) - - entryID := result["entry_id"].(string) - t.Logf("created entry %s with L1/L2/L3 fields", entryID) - - // ================================================================= - // TEST 1: API path — fields returned correctly per tier - // ================================================================= - t.Run("API_returns_all_tiers", func(t *testing.T) { - result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200) - data := result["data"].(map[string]any) - fields := data["fields"].([]any) - - found := map[string]bool{} - for _, raw := range fields { - f := raw.(map[string]any) - label := f["label"].(string) - value, _ := f["value"].(string) - - switch label { - case "Username": - if value != "testuser" { - t.Errorf("L1 'Username': expected 'testuser', got '%s'", value) - } else { - t.Log("PASS API L1 'Username' = plaintext readable") - } - found["L1"] = true - - case "Password": - if value != l2Blob { - t.Errorf("L2 'Password': expected encrypted blob, got '%s'", value) - } else { - t.Log("PASS API L2 'Password' = encrypted blob returned intact") - } - found["L2"] = true - - case "SSN": - if value != l3Blob { - t.Errorf("L3 'SSN': expected encrypted blob, got '%s'", value) - } else { - t.Log("PASS API L3 'SSN' = encrypted blob returned intact") - } - found["L3"] = true - } - } - - for _, tier := range []string{"L1", "L2", "L3"} { - if !found[tier] { - t.Errorf("missing %s field in response", tier) - } - } - }) - - // ================================================================= - // TEST 2: DB path — read raw from SQLite, decrypt L1 envelope - // ================================================================= - t.Run("DB_tier_isolation", func(t *testing.T) { - // Open the test DB directly - // Fetch via API — the server decrypts the L1 envelope (vault key), - // but L2/L3 field values inside remain as ciphertext blobs. - result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200) - data := result["data"].(map[string]any) - fields := data["fields"].([]any) - - for _, raw := range fields { - f := raw.(map[string]any) - label := f["label"].(string) - value, _ := f["value"].(string) - - switch label { - case "Password": - // Must NOT be a human-readable password - if value == "mypassword" || value == "secret" || value == "" { - t.Error("CRITICAL: L2 field contains plaintext in DB!") - } - // Must be the exact ciphertext blob we stored - if value == l2Blob { - t.Log("PASS DB L2 'Password' = stored as ciphertext") - } - - case "SSN": - // Must NOT be a readable SSN - if value == "123-45-6789" || value == "" { - t.Error("CRITICAL: L3 field contains plaintext in DB!") - } - if value == l3Blob { - t.Log("PASS DB L3 'SSN' = stored as ciphertext") - } - } - } - }) - - // ================================================================= - // TEST 3: Verify L2/L3 blobs survive roundtrip unchanged - // ================================================================= - t.Run("Blob_integrity", func(t *testing.T) { - result := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200) - data := result["data"].(map[string]any) - fields := data["fields"].([]any) - - for _, raw := range fields { - f := raw.(map[string]any) - label := f["label"].(string) - value, _ := f["value"].(string) - - switch label { - case "Password": - if value != l2Blob { - t.Errorf("L2 blob corrupted: stored '%s', got '%s'", l2Blob, value) - } else { - t.Log("PASS L2 blob integrity preserved") - } - case "SSN": - if value != l3Blob { - t.Errorf("L3 blob corrupted: stored '%s', got '%s'", l3Blob, value) - } else { - t.Log("PASS L3 blob integrity preserved") - } - } - } - }) - - // Cleanup - c.req("DELETE", "/api/entries/"+entryID, nil) -} - -/* - * TestTierIsolationDB — verifies tier isolation at the database level. - * - * Creates an entry, then reads the raw encrypted blob from SQLite, - * decrypts the L1 envelope with the vault key, and confirms: - * - L1 fields are plaintext - * - L2 field values are still encrypted (ciphertext blobs) - * - L3 field values are still encrypted (ciphertext blobs) - */ -func TestTierIsolationDB(t *testing.T) { - c := newTestClient(t) - - l2Blob := "L2_ENCRYPTED_BLOB_BASE64_DATA" - l3Blob := "L3_ENCRYPTED_BLOB_BASE64_DATA" - - // Create entry - result := c.must(c.req("POST", "/api/entries", map[string]any{ - "type": "credential", - "title": "DBTierTest", - "data": map[string]any{ - "title": "DBTierTest", - "type": "credential", - "fields": []map[string]any{ - {"label": "User", "value": "alice", "kind": "text"}, - {"label": "Key", "value": l2Blob, "kind": "password", "tier": 2}, - {"label": "Passport", "value": l3Blob, "kind": "text", "tier": 3, "l2": true}, - }, - }, - }), 201) - - entryID := result["entry_id"].(string) - entryIDInt, _ := lib.HexToID(entryID) - - // In the new stateless model, L1 key comes from Bearer token. - // The test client uses a session token, not L1 — this test - // verifies via API response, not direct DB access. - - // Open DB directly (same path pattern as newTestClient) - // The test creates DB at t.TempDir() + "/01020304.db" - // We need to find it... the test server opens it via config. - // Workaround: read the entry raw bytes from the DB via the test's internal DB handle. - - // Actually, we can use the API response to verify — the API reads from DB, - // decrypts L1 envelope, and returns fields. If L2/L3 values come back - // as the exact ciphertext we stored, it proves: - // 1. L1 envelope was decrypted (server has vault key) ✓ - // 2. L2/L3 values inside the envelope are untouched ciphertext ✓ - - resp := c.must(c.req("GET", "/api/entries/"+entryID, nil), 200) - data := resp["data"].(map[string]any) - fields := data["fields"].([]any) - - for _, raw := range fields { - f := raw.(map[string]any) - label := f["label"].(string) - value, _ := f["value"].(string) - - switch label { - case "User": - if value == "alice" { - t.Log("PASS DB→API: L1 'User' decrypted to plaintext") - } else { - t.Errorf("DB→API: L1 'User' expected 'alice', got '%s'", value) - } - - case "Key": - if value == l2Blob { - t.Log("PASS DB→API: L2 'Key' = ciphertext preserved through L1 decrypt") - } else { - t.Errorf("DB→API: L2 'Key' blob changed: '%s'", value) - } - - case "Passport": - if value == l3Blob { - t.Log("PASS DB→API: L3 'Passport' = ciphertext preserved through L1 decrypt") - } else { - t.Errorf("DB→API: L3 'Passport' blob changed: '%s'", value) - } - } - } - - _ = entryIDInt - - c.req("DELETE", "/api/entries/"+entryID, nil) -} - -/* - * TestCLICrypto — runs the CLI crypto test suite. - * Skipped if clavitor-cli binary not found. - */ -func TestCLICrypto(t *testing.T) { - // Find CLI binary via absolute path - home := os.Getenv("HOME") - cliBin := home + "/dev/clavitor/clavis/clavis-cli/clavis-cli" - cliDir := home + "/dev/clavitor/clavis/clavis-cli" - if _, err := os.Stat(cliBin); err != nil { - t.Skip("clavitor-cli not found — run 'make cli' first") - } - - t.Run("test-roundtrip", func(t *testing.T) { - cmd := exec.Command(cliBin, "test-roundtrip") - // Set working dir so QuickJS can find crypto/*.js - cmd.Dir = cliDir - out, err := cmd.CombinedOutput() - output := string(out) - t.Log(output) - if err != nil { - t.Fatalf("test-roundtrip failed: %v", err) - } - if !strings.Contains(output, "ALL 12 TESTS PASSED") { - t.Fatal("not all crypto tests passed") - } - }) - - t.Run("test-totp", func(t *testing.T) { - cmd := exec.Command(cliBin, "test-totp", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") - cmd.Dir = cliDir - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("test-totp failed: %v\n%s", err, out) - } - output := string(out) - parts := strings.Fields(output) - if len(parts) < 1 || len(parts[0]) != 6 { - t.Fatalf("expected 6-digit code, got: %s", output) - } - t.Logf("TOTP code: %s", parts[0]) - }) - - t.Run("test-crypto", func(t *testing.T) { - cmd := exec.Command(cliBin, "test-crypto") - cmd.Dir = cliDir - out, err := cmd.CombinedOutput() - output := string(out) - t.Log(output) - if err != nil { - t.Fatalf("test-crypto failed: %v", err) - } - if !strings.Contains(output, "PASS: all tests passed") { - t.Fatal("crypto self-test failed") - } - }) -} diff --git a/clavis/clavis-vault/clavitor-linux-amd64 b/clavis/clavis-vault/clavitor-linux-amd64 index 7900656..e1d6b45 100755 Binary files a/clavis/clavis-vault/clavitor-linux-amd64 and b/clavis/clavis-vault/clavitor-linux-amd64 differ diff --git a/clavis/clavis-vault/clavitor-linux-arm64 b/clavis/clavis-vault/clavitor-linux-arm64 index a1eb944..61141da 100755 Binary files a/clavis/clavis-vault/clavitor-linux-arm64 and b/clavis/clavis-vault/clavitor-linux-arm64 differ diff --git a/clavis/clavis-vault/cmd/clavitor/main.go b/clavis/clavis-vault/cmd/clavitor/main.go index 54bf65d..8a8c3d4 100644 --- a/clavis/clavis-vault/cmd/clavitor/main.go +++ b/clavis/clavis-vault/cmd/clavitor/main.go @@ -4,7 +4,6 @@ import ( "embed" "flag" "log" - "net/http" "os" "strconv" @@ -15,9 +14,6 @@ import ( //go:embed web var webFS embed.FS -//go:embed templates -var templateFS embed.FS - // Set via -ldflags at build time. var ( version = "dev" @@ -52,11 +48,11 @@ func main() { // Start automatic backup scheduler (3 weekly + 3 monthly, rotated) lib.StartBackupTimer(cfg.DataDir) - router := api.NewRouter(cfg, webFS, templateFS) + router := api.NewRouter(cfg, webFS) addr := ":" + cfg.Port - log.Printf("Clavitor listening on http://0.0.0.0%s", addr) - if err := http.ListenAndServe(addr, router); err != nil { + tlsCfg := lib.LoadTLSConfig() + if err := lib.ListenAndServeTLS(addr, router, tlsCfg); err != nil { log.Fatalf("server: %v", err) } } diff --git a/clavis/clavis-vault/cmd/clavitor/templates/base.html b/clavis/clavis-vault/cmd/clavitor/templates/base.html deleted file mode 100644 index 84ce999..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/base.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - {{.Title}} - {{if .Desc}}{{end}} - - - - - - {{if eq .Page "install"}}{{template "install-head"}}{{end}} - - - -{{if eq .Page "index"}}{{template "index" .}} -{{else if eq .Page "hosted"}}{{template "hosted" .}} -{{else if eq .Page "install"}}{{template "install" .}} -{{else if eq .Page "pricing"}}{{template "pricing" .}} -{{else if eq .Page "privacy"}}{{template "privacy" .}} -{{else if eq .Page "terms"}}{{template "terms" .}} -{{else if eq .Page "sources"}}{{template "sources" .}} -{{else if eq .Page "landing"}}{{template "landing" .}} -{{else if eq .Page "claude-code"}}{{template "claude-code" .}} -{{else if eq .Page "codex"}}{{template "codex" .}} -{{else if eq .Page "openclaw"}}{{template "openclaw" .}} -{{else if eq .Page "openclaw-cn"}}{{template "openclaw-cn" .}} -{{end}} -{{template "footer"}} -{{if eq .Page "index"}}{{template "index-script"}} -{{else if eq .Page "hosted"}}{{template "hosted-script"}} -{{else if eq .Page "landing"}}{{template "landing-script"}} -{{end}} - - diff --git a/clavis/clavis-vault/cmd/clavitor/templates/footer.html b/clavis/clavis-vault/cmd/clavitor/templates/footer.html deleted file mode 100644 index da1940c..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/footer.html +++ /dev/null @@ -1,20 +0,0 @@ -{{define "footer"}} - -{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/geo-pops.html b/clavis/clavis-vault/cmd/clavitor/templates/geo-pops.html deleted file mode 100644 index af6d639..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/geo-pops.html +++ /dev/null @@ -1,56 +0,0 @@ -{{define "geo-pops-script"}} - -{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/hosted.html b/clavis/clavis-vault/cmd/clavitor/templates/hosted.html deleted file mode 100644 index 3d6de88..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/hosted.html +++ /dev/null @@ -1,267 +0,0 @@ -{{define "hosted"}} - -
-

vault1984 hosted

-

Your vault needs to work everywhere. We make sure it does.

-

Your laptop, your phone, your browser extension — at home, at work, on the road. We run the infrastructure across 22 regions. You pick where your data lives. $20 $12/yr.

-
- - -
-
- - - - - - Washington D.C. - - - - San Francisco - - - - Montréal - - - - Mexico City - - - - Bogotá - - - - São Paulo - - - - Santiago - - - - London - - - - - Zürich - - - - Madrid - - - - Stockholm - - - - Istanbul - - - - Dubai - - - - Lagos - - - - Nairobi - - - - Cape Town - - - - Mumbai - - - - Singapore - - - - Sydney - - - - Tokyo - - - - Seoul - - - - Hong Kong - -
-
-
- -
-
🖥️
-
Self-hosted
-
Your machine. Your rules.
-
Free forever
- Download now → -
- -
-
🇨🇭
-
Zürich, Switzerland
-
Capital of Privacy
-
Headquarters
- Buy now → -
- -
-
📍
-
Nearest region
-
Locating you…
-
Closest to you
- Buy now → -
-
-
- -
- - -
-

Why Zürich

-

Sealed fields: jurisdiction irrelevant.
Agent fields: it isn't.

-

Sealed fields are protected by math — where the server sits doesn't matter. But agent fields live on a server in a jurisdiction. A US server is subject to the CLOUD Act. Zürich, Switzerland is subject to Swiss law — which does not cooperate with foreign government data requests. No backdoors. Both layers protected.

-
-
-

Self-hosted · US

-

Your server, your rules — until a court says otherwise. CLOUD Act applies to US persons regardless of encryption.

-
-
-

Self-hosted · anywhere

-

Full control. Your infrastructure, your jurisdiction. The right choice if you know what you're doing.

-
-
-

Hosted · Zürich, Switzerland

-

Swiss law. Swiss courts. Capital of Privacy. No CLOUD Act. No backdoors. We handle the infrastructure — you get the protection.

-
-
-
- -
- - -
-

What hosted adds

-

Everything in self-hosted, plus

-
-
-

Managed infrastructure

-

We run it, monitor it, and keep it up. You just use it.

-
-
-

Daily encrypted backups

-

Automatic daily backups. Encrypted at rest. Restorable on request.

-
-
-

22 regions

-

Pick your region at signup. Your data stays there. Every continent covered.

-
-
-

Automatic updates

-

Security patches and new features deployed automatically. No downtime.

-
-
-

TLS included

-

HTTPS out of the box. No Caddy, no certbot, no renewal headaches.

-
-
-

Email support

-

Real human support. Not a chatbot. Not a forum post into the void.

-
-
-
- -
- - -
-

Ready?

-

$20 $12/yr. 7-day money-back. Every feature included.

- -
-{{end}} - -{{define "hosted-script"}} -{{template "geo-pops-script"}} - -{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/index.html b/clavis/clavis-vault/cmd/clavitor/templates/index.html deleted file mode 100644 index 78a6ff7..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/index.html +++ /dev/null @@ -1,484 +0,0 @@ -{{define "index"}} - -
-
-

Password manager for the AI era

-

Passwords for AI agents.

-

Clavitor is a password manager built for humans who work with AI assistants. Your agent gets the credentials it needs via MCP, API, or CLI. You get everything — including the secrets agents should never see, locked behind your fingerprint. Same vault, different access.

- -
-
- -
-

Terminal

-
$ curl -fsSL clavitor.com/install.sh | sh
-
$ clavitor
-
# Running on http://localhost:1984
-
-
-

MCP config — Claude Code / Cursor / Codex

-
{
-  "mcpServers": {
-    "clavitor": {
-      "url": "http://localhost:1984/mcp",
-      "headers": {
-        "Authorization": "Bearer your_token"
-      }
-    }
-  }
-}
-
-
-
- -
- - -
-

The problem

-

Every password manager was built before AI agents existed. Now they need to catch up.

-
-
-
-

All-or-nothing is broken

-

All others give your AI agent access to everything in your vault, or nothing at all. Your AI needs your GitHub token — it shouldn't also see your passport number.

-
-
-
-

Policy isn't security

-

"AI-safe" vaults still decrypt everything server-side. If the server can read it, it's not truly private. Math beats policy every time.

-
-
-
-

Agents need credentials — and 2FA

-

Your AI can't log in, pass two-factor, or rotate keys without access. clavitor lets it do all three — without exposing your credit card to the same pipeline.

-
-
-
- -
- - -
-

How it works

-

"Your assistant can book your flights.
Not read your diary."

-

Every field is encrypted. But some get a second lock. That second key is derived from your fingerprint and only exists in your browser. We hold the safe. Only you hold that key.

-
-
- Agent fields -

AI-readable

-

Encrypted at rest, decryptable by the vault server. Your AI agent reads these via MCP.

-
    -
  • API keys & tokens
  • -
  • SSH keys
  • -
  • TOTP 2FA codes — AI generates them for you
  • -
  • OAuth tokens
  • -
  • Structured notes
  • -
-
-
- Sealed fields -

Touch ID only

-

Encrypted client-side with WebAuthn PRF. The server never sees the plaintext. Ever.

-
    -
  • Credit card numbers
  • -
  • CVV
  • -
  • Passport & SSN
  • -
  • Private signing keys
  • -
  • Private notes
  • -
-
-
-
- -
- - -
-

Built different

-

Not another password manager with an AI checkbox. The architecture is the feature.

-
-
-
-

Field-level AI visibility

-

Each field has its own encryption tier. Your AI reads the username, not the CVV. Same entry, different access.

-
-
-
-

WebAuthn PRF

-

Sealed encryption uses WebAuthn PRF — a cryptographic key derived from your biometric hardware. Math, not policy. We literally cannot decrypt it.

-
-
-
-

AI-powered 2FA

-

Store TOTP secrets as Agent fields. Your AI generates time-based codes on demand via MCP — no more switching to your phone.

-
-
-
-

Scoped MCP tokens

-

Create separate MCP tokens per agent. Each token sees only its designated entries. Compromise one, the rest stay clean.

-
-
-
-

One binary, one file

-

No Docker. No Postgres. No Redis. One Go binary, one SQLite file. Runs on a Raspberry Pi. Runs on a $4/month VPS.

-
-
-
-

LLM field mapping

-

Import from any password manager. The built-in LLM automatically classifies which fields should be Agent vs Sealed.

-
-
-
- -
- - -
-
-
-

10 agents.
Each gets exactly what it needs.

-

Create scoped MCP tokens per agent. One compromised agent exposes one scope — not your entire vault.

-
-

~/.claude/mcp.json

-
{
-  "mcpServers": {
-    "vault-dev": {
-      "url": "http://localhost:1984/mcp",
-      "headers": { "Authorization": "Bearer mcp_dev_a3f8..." }
-    },
-    "vault-social": {
-      "url": "http://localhost:1984/mcp",
-      "headers": { "Authorization": "Bearer mcp_social_7b2e..." }
-    }
-  }
-}
-
-
-
- - - - - vault - 1984 - - - - Agent 1 - dev - - - - - Agent 2 - social - - - - - Agent 3 - finance - - - - - Agent 4 - infra - - - - - Agent 5 - deploy - - - - - github ssh gitlab - - - twitter slack discord - - - stripe plaid - - - aws k8s docker - - - vercel netlify - -
-
-
- -
- - -
-

Your agent and you — same vault, right access

-

Four ways in. Each one designed for a different context. All pointing at the same encrypted store.

-
-
-

MCP

-

For AI agents

-

Claude, GPT, or any MCP-compatible agent can search credentials, fetch API keys, and generate 2FA codes — scoped to exactly what you allow.

-
-
-

Extension

-

For humans in a browser

-

Autofill passwords, generate 2FA codes inline, and unlock L2 fields with Touch ID — without leaving the page you're on.

-
-
-

CLI

-

For terminal workflows

-

Pipe credentials directly into scripts and CI pipelines. vault get github.token — done.

-
-
-

API

-

For everything else

-

REST API with scoped tokens. Give your deployment pipeline read access to staging keys. Nothing else.

-
-
-
- -
- - -
-

The competition

-

We listened. And addressed them all.

-

Real complaints from real users — about 1Password, Bitwarden, and LastPass. Pulled from forums, GitHub issues, and Hacker News. Not cherry-picked from our own users.

- -
- -
-

1PASSWORD — Community Forum

-

"The web extensions are laughably bad at this point. This has been going on for months. They either won't fill, wont' unlock, or just plain won't do anything (even clicking extension icon). It's so bad"

-

— notnotjake, April 2024 ↗

-
-
    -
  • clavitor: No desktop app dependency. The extension talks directly to the local vault binary — no IPC, no sync, no unlock chains.
  • -
-
- -
-

BITWARDEN — GitHub Issues

-

"Every single website loads slower. From Google, up to social media websites like Reddit, Instagram, X up to websites like example.com. Even scrolling and animation stutters sometimes. javascript heavy websites like X, Instagram, Reddit etc. become extremely sluggish when interacting with buttons. So for me the Bitwarden browser extension is unusable. It interferes with my browsing experience like malware."

-

— julianw1011, 2024 ↗

-
-
    -
  • clavitor: Zero content scripts. The extension injects nothing into pages — it fills via the browser autofill API only when you ask.
  • -
-
- -
-

LASTPASS — Hacker News

-

"The fact they're drip-feeding how bad this breach actually was is terrible enough... Personally I'm never touching them again."

-

— intunderflow, January 2023 ↗

-
-
    -
  • clavitor: Self-host or use hosted with L2 encryption — we mathematically cannot read your private fields. No vault data to breach.
  • -
-
- -
-

1PASSWORD — Community Forum

-

"Since doing so, it asks me to enter my password every 10 minutes or so in the chrome extension"

-

— Anonymous (Former Member), November 2022 ↗

-
-
    -
  • clavitor: WebAuthn-first. Touch ID is the primary unlock. Session lives locally — no server-side expiry forcing re-auth.
  • -
-
- -
-

BITWARDEN — Community Forums

-

"the password not only auto-filled in the password field, but also auto-filled in reddit's search box!"

-

"if autofill has the propensity at times to put an entire password in plain text in a random field, autofill seems like more risk than it's worth."

-

— xru1nib5 ↗

-
-
    -
  • clavitor: LLM field mapping. The extension reads the form, asks the model which field is which — fills by intent, not by CSS selector.
  • -
-
- -
-

BITWARDEN — Community Forums

-

"Bitwarden REFUSES to autofill the actual password saved for a given site or app...and instead fills an old password. It simply substitutes the OLD password for the new one that is plainly saved in the vault."

-

— gentlezacharias ↗

-
-
    -
  • clavitor: LLM field mapping matches by intent. Entries are indexed by URL — the right credential for the right site, every time.
  • -
-
- -
- -

All quotes verbatim from public posts. URLs verified. View sources →

-
- -
- - -
-

Your vault needs to be everywhere you are.

-

A password manager that only works on your home network isn't a password manager. Your laptop moves. Your phone moves. Your browser extension needs your vault at the coffee shop, on the plane, at the client's office.

-

Self-hosting that means a server with a public IP, DNS, TLS certificates, uptime monitoring, and backups. That's not a weekend project — that's infrastructure.

-

We run clavitor across 22 regions on every continent. $20 $12/yr. Your Sealed keys never leave your browser — we mathematically cannot read your private fields.

- -
- -
- - -
-

Up and running in 30 seconds

-

One command. No dependencies.

-
-

Terminal

-
# Self-host in 30 seconds
-
$ curl -fsSL clavitor.com/install.sh | sh
-
$ clavitor
-
# Running on http://localhost:1984
-
-
-

MCP config for Claude Code / Cursor / Codex

-
{
-  "mcpServers": {
-    "clavitor": {
-      "url": "http://localhost:1984/mcp",
-      "headers": { "Authorization": "Bearer mcp_your_token_here" }
-    }
-  }
-}
-
-

Full install guide →

-
-{{end}} - -{{define "index-script"}} - -{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/install.html b/clavis/clavis-vault/cmd/clavitor/templates/install.html deleted file mode 100644 index dbef9bd..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/install.html +++ /dev/null @@ -1,129 +0,0 @@ -{{define "install-head"}} - -{{end}} - -{{define "install"}} -
-

Open source · MIT

-

Self-host clavitor

-

One binary. No Docker. No Postgres. No Redis. Runs anywhere Go runs. You'll need a server with a public IP, DNS, and TLS if you want access from outside your network.

-
- -
- -
- -
-
1
-
-

Download

-

The install script detects your OS and architecture, downloads the latest release, and puts it in your PATH.

-
$ curl -fsSL clavitor.com/install.sh | sh
-

Or download directly:

- -
-
- -
-
2
-
-

Set your vault key

-

The vault key encrypts your Agent field data at rest. If you lose this key, Agent field data cannot be recovered.

-
-
# Generate a random key
-
$ export VAULT_KEY=$(openssl rand -hex 32)
-
# Save it somewhere safe
-
$ echo $VAULT_KEY >> ~/.clavitor-key
-
-
-
- -
-
3
-
-

Run it

-

A SQLite database is created automatically in ~/.clavitor/.

-
-
$ clavitor
-
clavitor running on http://localhost:1984
-
Database: ~/.clavitor/vault.db
-
-
-
- -
-
4
-
-

Configure MCP

-

Point your AI assistant at the vault. Works with Claude Code, Cursor, Codex, or any MCP-compatible client.

-

~/.claude/mcp.json

-
{
-  "mcpServers": {
-    "clavitor": {
-      "url": "http://localhost:1984/mcp",
-      "headers": {
-        "Authorization": "Bearer YOUR_MCP_TOKEN"
-      }
-    }
-  }
-}
-

Generate an MCP token from the web UI at http://localhost:1984 after first run.

-
-
- -
-
5
-
-

Import your passwords

-

The LLM classifier automatically suggests Agent/Sealed assignments for each field. Review and confirm in the web UI.

-
-
# Chrome, Firefox, Bitwarden, Proton Pass, 1Password
-
$ clavitor import --format chrome passwords.csv
-
$ clavitor import --format bitwarden export.json
-
$ clavitor import --format 1password export.json
-
-
-
- -
- -

Run as a service

-

For always-on availability, run clavitor as a systemd service.

-

/etc/systemd/system/clavitor.service

-
[Unit]
-Description=clavitor
-After=network.target
-
-[Service]
-Type=simple
-User=clavitor
-EnvironmentFile=/etc/clavitor/env
-ExecStart=/usr/local/bin/clavitor
-Restart=always
-RestartSec=5
-
-[Install]
-WantedBy=multi-user.target
-
$ sudo systemctl enable --now clavitor
- -

Expose to the internet

-

Put clavitor behind Caddy for TLS and remote access.

-

Caddyfile

-
vault.yourdomain.com {
-    reverse_proxy localhost:1984
-}
- -
- -
- -
-

Rather not manage it yourself?

-

Same vault, same features. We handle updates, backups, and TLS. $20 $12/yr.

- See hosted option → -
-{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/landing.html b/clavis/clavis-vault/cmd/clavitor/templates/landing.html deleted file mode 100644 index 4fce123..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/landing.html +++ /dev/null @@ -1,374 +0,0 @@ -{{define "landing"}} - - - -
- - -
-
-

Password manager for the AI era

-

Passwords for
AI agents.

-

Claude Code, Codex, Cursor, OpenClaw — every AI agent needs credentials. clavitor gives them access without giving them everything. Your private data stays locked behind your biometric. Same vault, different access.

- -
- MCP - REST API - CLI - Browser Extension - Open Source -
-
-
- -
-
- - Visa • John Smith -
-
-
- cardholder - John Smith - AI + you -
-
- expiry - 2029-02 - AI + you -
-
- number - 4532 •••• •••• 7821 - - - only you - -
-
- cvv - ••• - - - only you - -
-
- -
-
-
- -
- - -
-

See it in action

-

What makes clavitor different.

-
- - -
-
-

Agent fetches a credential

-

Your AI searches the vault via MCP and gets exactly what it needs.

-
-
-
-
claude> search_vault("github")
-
...
-
token: ghp_x7k2m9...4f1a
-
ssh: ed25519 SHA256:...
-
totp: 847 291
-
-
-
- - -
-
-

Private fields stay private

-

The agent asks for your Visa. It gets the name. Not the number.

-
-
-
-
claude> get_credential("visa")
-
...
-
name: John Smith
-
number: [REDACTED]
-
cvv: [REDACTED]
-
-
-
- - -
-
-

AI generates your 2FA

-

No phone. No app switching. Your agent handles two-factor.

-
-
-
-
claude> get_totp("aws")
-
generating code...
-
code: 847 291
-
expires: 18s
-
account: john@smith.family
-
-
-
-
-
- - -
-
-

This is the vault for the AI era.

-

Not an add-on to some legacy product.

-

Vault connectors give your AI the same access you have. Everything or nothing. That's not security, that's a checkbox.

-
-
-
-

Connectors: all-or-nothing

-

Your agent needs your GitHub token. With a connector, it also sees your SSN and private keys.

-
-
-
-

"AI-safe" is policy, not math

-

Other vaults decrypt everything server-side and filter. If the server can read it, it's not private.

-
-
-
-

Clavitor: built for this

-

Your AI reads what you allow. Private fields are encrypted with a key derived from your biometric — it never leaves your device. Not your AI, not us, not our servers can decrypt them.

-
-
-
-
- - -
-
-

"Your AI deploys the code.
It doesn't see what you don't share."

-

You decide which fields are private — per entry, per field. Those get a second encryption key derived from your biometric via WebAuthn PRF. That key is generated on your device and never transmitted.

-
-
- 1. - You mark a field as private in the web UI - 2. - Your browser derives an AES-256 key from your biometric via WebAuthn PRF (Touch ID, YubiKey, etc.) - 3. - The field is encrypted client-side before it ever reaches the server - 4. - The server stores ciphertext. No key, no plaintext, no backdoor. Math, not policy. -
-
-
-
- - -
-
-
- Your agent can read -

Shared with AI

-
-
GET /api/entries/github
-
username: johnsmith
-
token: ghp_x7k2m9q...4f1a
-
ssh_key: -----BEGIN OPENSSH...
-
totp: 847291 (18s remaining)
-
-
-
- Only you can read -

Locked to your biometric

-
-
GET /api/entries/identity
-
name: John Smith
-
passport: [REDACTED — not available to agents]
-
ssn: [REDACTED — not available to agents]
-
email: john@smith.family
-
-
-
-
- -
- - -
-
-
1
Binary
-
1
SQLite file
-
5
MCP tools
-
0
Dependencies
-
-
- -
- - -
-
-
-

Multi-user. Multi-agent.
One vault.

-

Every agent gets its own API key. Your coding agent sees GitHub. Your DevOps agent sees AWS. Neither sees your personal keys.

-
-
-
-

~/.claude/mcp.json

-
{
-  "mcpServers": {
-    "vault-dev": {
-      "url": "http://localhost:1984/mcp",
-      "headers": { "Authorization": "Bearer token_dev_..." }
-    },
-    "vault-devops": {
-      "url": "http://localhost:1984/mcp",
-      "headers": { "Authorization": "Bearer token_ops_..." }
-    }
-  }
-}
-
-
-
-
- -
- - -
-

Two ways to run it.

-
-
-

Hosted — $20 $12/yr (personal)

-

Launch price until June 30, 2026

-

We handle TLS, DNS, backups and 21 regional edge nodes across 6 continents. You just create a vault and connect your agents.

- - Get hosted → -
-
-

Self-host — free

-

One binary on your server. You'll need a domain, reverse proxy and TLS. Full control, zero cost.

-
-
$ curl -fsSL clavitor.com/install.sh | sh
-
$ clavitor
-
# Running on http://localhost:1984
-
- Install guide → -
-
-

Need team or enterprise pricing? Talk to us.

-
- -
-{{end}} - -{{define "landing-script"}} -{{template "geo-pops-script"}} - -{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/pricing.html b/clavis/clavis-vault/cmd/clavitor/templates/pricing.html deleted file mode 100644 index 7fe35e7..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/pricing.html +++ /dev/null @@ -1,58 +0,0 @@ -{{define "pricing"}} -
-

Simple pricing

-

No tiers. No per-seat. No surprises.

-

Two options — both get every feature.

-
- -
- -
-
- -
-

Self-hosted

-
Free
-

Forever. MIT license. No strings.

- Self-host guide → -

What you get

-
  • Agent & Sealed field-level encryption
  • WebAuthn PRF (Sealed biometric encryption)
  • MCP server for AI agents
  • Multi-agent API keys
  • TOTP generation via MCP
  • Browser extension (Chrome, Firefox)
  • Import from Bitwarden / 1Password
  • LLM-powered field classification
  • Unlimited entries
  • Full source code (MIT)
-
- - - -
-
- -
- -
-

Common questions

-

FAQ

- -
-

Why so cheap?

-

$12/yr is launch pricing — regular price is $20/yr. Both cover compute, backups, and bandwidth for one user for a year.

- -

Is the self-hosted version missing any features?

-

No. Every feature ships in both versions. Hosted adds managed infrastructure and backups — not functionality.

- -

Can hosted clavitor read my Sealed fields?

-

No. Sealed fields are encrypted client-side with WebAuthn PRF. The server stores ciphertext it cannot decrypt. This isn't a policy — it's mathematics. We don't have the key.

- -

Can I switch between hosted and self-hosted?

-

Yes. Export your vault at any time as encrypted JSON. Import it anywhere. Your data is always portable.

- -

Is there a free trial?

-

No free trial — but 7-day money-back, no questions asked, instant refund. That's a stronger guarantee.

-
-
-{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/privacy.html b/clavis/clavis-vault/cmd/clavitor/templates/privacy.html deleted file mode 100644 index 561c012..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/privacy.html +++ /dev/null @@ -1,65 +0,0 @@ -{{define "privacy"}} -
-

Legal

-

Privacy Policy

-

No analytics. No tracking. No data sales.

-

Last updated: February 2026

-
- -
- -
-
- -

The short version

-
    -
  • Your vault data is encrypted at rest (Agent fields) and in transit (TLS).
  • -
  • Sealed fields are encrypted client-side with WebAuthn PRF. We cannot decrypt them. Ever.
  • -
  • No analytics. No tracking pixels. No third-party scripts.
  • -
  • We don't sell, share, or rent your data. To anyone. For any reason.
  • -
  • You can delete your account and all data at any time.
  • -
- -

What this policy covers

-

This privacy policy applies to the hosted clavitor service at clavitor.com. If you self-host clavitor, your data never touches our servers and this policy doesn't apply to you — your privacy is entirely in your own hands.

- -

Data we store

-

When you use hosted clavitor, we store:

-
    -
  • Account information: email address and authentication credentials
  • -
  • Agent field data: encrypted at rest with AES-256-GCM using your vault key
  • -
  • Sealed field data: encrypted client-side with WebAuthn PRF before reaching our servers — stored as ciphertext we cannot decrypt
  • -
  • Metadata: entry creation and modification timestamps, entry titles
  • -
- -

Data we don't store

-
    -
  • IP address logs (not stored beyond immediate request processing)
  • -
  • Usage analytics or telemetry
  • -
  • Browser fingerprints
  • -
  • Cookies beyond session authentication
  • -
- -

Sealed field encryption guarantee

-

Fields marked as Sealed are encrypted in your browser using a key derived from your WebAuthn authenticator (Touch ID, Windows Hello, or a hardware security key) via the PRF extension. The encryption key never leaves your device. Our servers store only the resulting ciphertext. We cannot decrypt Sealed fields, and no future policy change, acquisition, or legal order can change this — the mathematical reality is that we don't have the key.

- -

Data residency

-

When you create a hosted vault, you choose a region. Your data stays in that region. We don't replicate across regions unless you explicitly request it.

- -

Third parties

-

We use infrastructure providers (cloud hosting, DNS) to run the service. These providers process encrypted data in transit but do not have access to your vault contents. We do not use any analytics services, advertising networks, or data brokers.

- -

Law enforcement

-

If compelled by valid legal process, we can only provide: your email address, account creation date, and encrypted vault data. Agent field data is encrypted with your vault key (which we do not store). Sealed field data is encrypted client-side. In practice, we have very little useful information to provide. The Zürich jurisdiction provides additional legal protections against foreign government requests.

- -

Account deletion

-

You can delete your account and all associated data at any time from the web interface. Deletion is immediate and irreversible. Backups containing your data are rotated out within 30 days.

- -

Changes to this policy

-

We'll notify registered users by email before making material changes to this policy. The current version is always available at this URL.

- -

Contact

-

Questions about this policy? Email privacy@clavitor.com.

-
-
-{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/sources.html b/clavis/clavis-vault/cmd/clavitor/templates/sources.html deleted file mode 100644 index be27ecf..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/sources.html +++ /dev/null @@ -1,45 +0,0 @@ -{{define "sources"}} -
-

Sources

-

Real users. Real quotes.

-

All quotes verbatim from public posts. URLs verified.

-
- -
- -
-
- -
-

"I tried giving Claude access to 1Password and it immediately wanted to read my credit card details. That's not what I wanted. clavitor is the only thing that solves this properly."

-

@devrel_mike · X · 2024

-
- -
-

"The L1/L2 split is genius. My home automation agent has the API keys it needs. It has never seen my passport number. That's exactly the boundary I wanted."

-

@homelab_nerd · Hacker News · 2024

-
- -
-

"Finally. A password manager that was actually designed for the AI era, not retrofitted for it."

-

@ai_tools_weekly · Substack · 2025

-
- -
-

"clavitor LLM field mapping matches by intent. Entries are indexed by URL — the right credential for the right site, every time."

-

@jolaneti11 · X · 2024

-
- -
-

"Zero content scripts. The extension injects nothing into pages — it fills via the browser autofill API only when you ask."

-

@securityreviewer · Reddit · 2024

-
- -
- -

- All quotes verbatim from public posts. URLs verified.   - View sources → -

-
-{{end}} diff --git a/clavis/clavis-vault/cmd/clavitor/templates/terms.html b/clavis/clavis-vault/cmd/clavitor/templates/terms.html deleted file mode 100644 index fab5c02..0000000 --- a/clavis/clavis-vault/cmd/clavitor/templates/terms.html +++ /dev/null @@ -1,53 +0,0 @@ -{{define "terms"}} -
-

Legal

-

Terms of Service

-

Last updated: February 2026

-
- -
- -
-
- -

1. Acceptance

-

By using clavitor (the "Service"), you agree to these terms. If you don't agree, don't use the Service.

- -

2. Description

-

clavitor is a password manager with field-level two-tier encryption. The hosted service stores encrypted vault data on your behalf. The self-hosted version (MIT licensed) runs entirely on your own infrastructure.

- -

3. Accounts

-

You are responsible for maintaining the security of your account credentials and authenticator device. We cannot recover Sealed fields if you lose access to your WebAuthn authenticator — the mathematical design prevents it.

- -

4. Acceptable use

-

You may not use the Service to store illegal content, conduct attacks, or violate applicable law. We reserve the right to suspend accounts that violate these terms.

- -

5. Payment

-

Hosted service is billed annually at $20/year (promotional pricing may apply). You have 7 days from payment to request a full refund — no questions asked, instant. After 7 days, no refunds are issued.

- -

6. Data ownership

-

Your vault data is yours. We claim no rights to it. You can export or delete it at any time.

- -

7. Service availability

-

We aim for high availability but make no uptime guarantees. Scheduled maintenance will be announced in advance. We are not liable for data loss or unavailability beyond making reasonable efforts to maintain backups.

- -

8. Encryption limitations

-

Agent fields (server-encrypted) provide strong encryption at rest and in transit. Sealed fields (client-encrypted) provide an additional layer that even we cannot break. However, no system is perfectly secure. You use the Service at your own risk.

- -

9. Termination

-

You may delete your account at any time. We may suspend accounts that violate these terms. Upon termination, your data is deleted from active systems immediately and purged from backups within 30 days.

- -

10. Limitation of liability

-

The Service is provided "as is." To the maximum extent permitted by applicable law, we are not liable for indirect, incidental, or consequential damages arising from your use of the Service.

- -

11. Governing law

-

These terms are governed by the laws of Switzerland. Disputes will be resolved in the courts of Zürich, Switzerland.

- -

12. Changes

-

We'll notify users by email before making material changes to these terms.

- -

Contact

-

Questions? Email legal@clavitor.com.

-
-
-{{end}} diff --git a/clavis/clavis-vault/go.mod b/clavis/clavis-vault/go.mod index b7aa827..9ae6495 100644 --- a/clavis/clavis-vault/go.mod +++ b/clavis/clavis-vault/go.mod @@ -11,4 +11,23 @@ require ( golang.org/x/crypto v0.48.0 ) -require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect +require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/caddyserver/certmagic v0.25.2 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/cloudflare v0.2.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect +) diff --git a/clavis/clavis-vault/go.sum b/clavis/clavis-vault/go.sum index 6cb2968..a1e297b 100644 --- a/clavis/clavis-vault/go.sum +++ b/clavis/clavis-vault/go.sum @@ -1,5 +1,9 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -8,8 +12,18 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -17,5 +31,25 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/clavis/clavis-vault/lib/crypto_test.go b/clavis/clavis-vault/lib/crypto_test.go new file mode 100644 index 0000000..b6f8569 --- /dev/null +++ b/clavis/clavis-vault/lib/crypto_test.go @@ -0,0 +1,188 @@ +package lib + +import ( + "testing" +) + +func TestNormalizeKey_8bytes(t *testing.T) { + key := []byte{1, 2, 3, 4, 5, 6, 7, 8} + got := NormalizeKey(key) + if len(got) != 16 { + t.Fatalf("expected 16 bytes, got %d", len(got)) + } + // First 8 bytes = original, second 8 bytes = copy + for i := 0; i < 8; i++ { + if got[i] != key[i] { + t.Errorf("byte %d: expected %d, got %d", i, key[i], got[i]) + } + if got[i+8] != key[i] { + t.Errorf("byte %d: expected %d (doubled), got %d", i+8, key[i], got[i+8]) + } + } +} + +func TestNormalizeKey_16bytes_passthrough(t *testing.T) { + key := make([]byte, 16) + for i := range key { + key[i] = byte(i) + } + got := NormalizeKey(key) + if len(got) != 16 { + t.Fatalf("expected 16 bytes, got %d", len(got)) + } + for i := range key { + if got[i] != key[i] { + t.Errorf("byte %d changed: expected %d, got %d", i, key[i], got[i]) + } + } +} + +func TestNormalizeKey_32bytes_passthrough(t *testing.T) { + key := make([]byte, 32) + got := NormalizeKey(key) + if len(got) != 32 { + t.Fatalf("expected 32 bytes, got %d", len(got)) + } +} + +func TestDeriveEntryKey_deterministic(t *testing.T) { + l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + key1, err := DeriveEntryKey(l1, 12345) + if err != nil { + t.Fatal(err) + } + key2, err := DeriveEntryKey(l1, 12345) + if err != nil { + t.Fatal(err) + } + if string(key1) != string(key2) { + t.Error("same inputs must produce same key") + } +} + +func TestDeriveEntryKey_different_entries_different_keys(t *testing.T) { + l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + key1, _ := DeriveEntryKey(l1, 1) + key2, _ := DeriveEntryKey(l1, 2) + if string(key1) == string(key2) { + t.Error("different entry IDs must produce different keys") + } +} + +func TestDeriveHMACKey_deterministic(t *testing.T) { + l1 := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} + key1, _ := DeriveHMACKey(l1) + key2, _ := DeriveHMACKey(l1) + if string(key1) != string(key2) { + t.Error("same L1 must produce same HMAC key") + } + if len(key1) != 32 { + t.Errorf("HMAC key should be 32 bytes, got %d", len(key1)) + } +} + +func TestBlindIndex_deterministic(t *testing.T) { + hmacKey := make([]byte, 32) + idx1 := BlindIndex(hmacKey, "test") + idx2 := BlindIndex(hmacKey, "test") + if string(idx1) != string(idx2) { + t.Error("same input must produce same blind index") + } + if len(idx1) != 16 { + t.Errorf("blind index should be 16 bytes, got %d", len(idx1)) + } +} + +func TestBlindIndex_different_inputs(t *testing.T) { + hmacKey := make([]byte, 32) + idx1 := BlindIndex(hmacKey, "apple") + idx2 := BlindIndex(hmacKey, "orange") + if string(idx1) == string(idx2) { + t.Error("different inputs should produce different blind indexes") + } +} + +func TestPackUnpack_roundtrip(t *testing.T) { + key := make([]byte, 16) // AES-128 + for i := range key { + key[i] = byte(i + 1) + } + + original := `{"title":"GitHub","fields":[{"label":"password","value":"hunter2"}]}` + packed, err := Pack(key, original) + if err != nil { + t.Fatalf("Pack: %v", err) + } + + if string(packed) == original { + t.Fatal("Pack must not return plaintext") + } + + unpacked, err := Unpack(key, packed) + if err != nil { + t.Fatalf("Unpack: %v", err) + } + + if unpacked != original { + t.Errorf("roundtrip failed:\n want: %s\n got: %s", original, unpacked) + } +} + +func TestPackUnpack_wrong_key_fails(t *testing.T) { + key1 := make([]byte, 16) + key2 := make([]byte, 16) + key2[0] = 0xFF + + packed, err := Pack(key1, "secret data") + if err != nil { + t.Fatal(err) + } + + _, err = Unpack(key2, packed) + if err == nil { + t.Fatal("Unpack with wrong key should fail") + } +} + +func TestPackUnpack_empty_string(t *testing.T) { + key := make([]byte, 16) + result, err := Unpack(key, nil) + if err != nil { + t.Fatal(err) + } + if result != "" { + t.Errorf("Unpack of nil should return empty string, got %q", result) + } +} + +func TestPackUnpack_AES256(t *testing.T) { + key := make([]byte, 32) // AES-256 + for i := range key { + key[i] = byte(i) + } + + original := "AES-256 test payload" + packed, err := Pack(key, original) + if err != nil { + t.Fatal(err) + } + + unpacked, err := Unpack(key, packed) + if err != nil { + t.Fatal(err) + } + if unpacked != original { + t.Errorf("AES-256 roundtrip failed: got %q", unpacked) + } +} + +func TestGenerateToken_length_and_uniqueness(t *testing.T) { + t1 := GenerateToken() + t2 := GenerateToken() + if len(t1) != 64 { + t.Errorf("token should be 64 hex chars, got %d", len(t1)) + } + if t1 == t2 { + t.Error("two generated tokens should not be equal") + } +} diff --git a/clavis/clavis-vault/lib/dbcore.go b/clavis/clavis-vault/lib/dbcore.go index ef070b4..0b0bff5 100644 --- a/clavis/clavis-vault/lib/dbcore.go +++ b/clavis/clavis-vault/lib/dbcore.go @@ -71,18 +71,6 @@ CREATE TABLE IF NOT EXISTS webauthn_challenges ( created_at INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS mcp_tokens ( - id INTEGER PRIMARY KEY, - label TEXT NOT NULL, - token TEXT UNIQUE NOT NULL, - tags TEXT, - entry_ids TEXT, - read_only INTEGER NOT NULL DEFAULT 0, - expires_at INTEGER NOT NULL DEFAULT 0, - last_used INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL -); - CREATE TABLE IF NOT EXISTS agents ( id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL, @@ -551,109 +539,6 @@ func EntryCount(db *DB) (int, error) { return count, err } -// --------------------------------------------------------------------------- -// MCP Token operations -// --------------------------------------------------------------------------- - -// CreateMCPToken inserts a new scoped MCP token. -func CreateMCPToken(db *DB, t *MCPToken) error { - if t.ID == 0 { - t.ID = HexID(NewID()) - } - if t.Token == "" { - t.Token = GenerateToken() - } - if t.CreatedAt == 0 { - t.CreatedAt = time.Now().Unix() - } - - idsJSON, _ := json.Marshal(t.EntryIDs) - - readOnly := 0 - if t.ReadOnly { - readOnly = 1 - } - - _, err := db.Conn.Exec( - `INSERT INTO mcp_tokens (id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - int64(t.ID), t.Label, t.Token, "[]", string(idsJSON), readOnly, t.ExpiresAt, t.LastUsed, t.CreatedAt, - ) - return err -} - -// ListMCPTokens returns all MCP tokens. -func ListMCPTokens(db *DB) ([]MCPToken, error) { - rows, err := db.Conn.Query( - `SELECT id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at - FROM mcp_tokens ORDER BY created_at DESC`, - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var tokens []MCPToken - for rows.Next() { - var t MCPToken - var tagsStr, idsStr string - var readOnly int - if err := rows.Scan(&t.ID, &t.Label, &t.Token, &tagsStr, &idsStr, &readOnly, &t.ExpiresAt, &t.LastUsed, &t.CreatedAt); err != nil { - return nil, err - } - t.ReadOnly = readOnly != 0 - if idsStr != "" { - json.Unmarshal([]byte(idsStr), &t.EntryIDs) - } - tokens = append(tokens, t) - } - return tokens, rows.Err() -} - -// GetMCPTokenByValue looks up an MCP token by its raw token string. -func GetMCPTokenByValue(db *DB, tokenValue string) (*MCPToken, error) { - var t MCPToken - var tagsStr, idsStr string - var readOnly int - err := db.Conn.QueryRow( - `SELECT id, label, token, tags, entry_ids, read_only, expires_at, last_used, created_at - FROM mcp_tokens WHERE token = ?`, tokenValue, - ).Scan(&t.ID, &t.Label, &t.Token, &tagsStr, &idsStr, &readOnly, &t.ExpiresAt, &t.LastUsed, &t.CreatedAt) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - t.ReadOnly = readOnly != 0 - if idsStr != "" { - json.Unmarshal([]byte(idsStr), &t.EntryIDs) - } - return &t, nil -} - -// DeleteMCPToken deletes an MCP token by ID. -func DeleteMCPToken(db *DB, id int64) error { - result, err := db.Conn.Exec(`DELETE FROM mcp_tokens WHERE id = ?`, id) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected == 0 { - return ErrNotFound - } - return nil -} - -// UpdateMCPTokenLastUsed updates the last_used timestamp on an MCP token. -func UpdateMCPTokenLastUsed(db *DB, id int64) error { - _, err := db.Conn.Exec(`UPDATE mcp_tokens SET last_used = ? WHERE id = ?`, time.Now().Unix(), id) - return err -} - // --------------------------------------------------------------------------- // WebAuthn credential operations // --------------------------------------------------------------------------- diff --git a/clavis/clavis-vault/lib/dbcore_test.go b/clavis/clavis-vault/lib/dbcore_test.go new file mode 100644 index 0000000..6ebbf0c --- /dev/null +++ b/clavis/clavis-vault/lib/dbcore_test.go @@ -0,0 +1,324 @@ +package lib + +import ( + "testing" +) + +// testDB creates a temp database, migrates it, returns DB + cleanup. +func testDB(t *testing.T) *DB { + t.Helper() + db, err := OpenDB(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + if err := MigrateDB(db); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// testVaultKey returns a fixed 16-byte key for testing. +func testVaultKey() []byte { + return []byte{1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8} +} + +func TestEntryCreate_and_Get(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + entry := &Entry{ + Type: TypeCredential, + Title: "GitHub", + VaultData: &VaultData{ + Title: "GitHub", + Type: "credential", + Fields: []VaultField{ + {Label: "username", Value: "octocat", Kind: "text"}, + {Label: "password", Value: "ghp_abc123", Kind: "password"}, + }, + URLs: []string{"https://github.com"}, + }, + } + + if err := EntryCreate(db, vk, entry); err != nil { + t.Fatalf("create: %v", err) + } + if entry.EntryID == 0 { + t.Fatal("entry ID should be assigned") + } + if entry.Version != 1 { + t.Errorf("initial version should be 1, got %d", entry.Version) + } + + got, err := EntryGet(db, vk, int64(entry.EntryID)) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Title != "GitHub" { + t.Errorf("title = %q, want GitHub", got.Title) + } + if got.VaultData == nil { + t.Fatal("VaultData should be unpacked") + } + if len(got.VaultData.Fields) != 2 { + t.Fatalf("expected 2 fields, got %d", len(got.VaultData.Fields)) + } + if got.VaultData.Fields[0].Value != "octocat" { + t.Errorf("username = %q, want octocat", got.VaultData.Fields[0].Value) + } + if got.VaultData.Fields[1].Value != "ghp_abc123" { + t.Errorf("password = %q, want ghp_abc123", got.VaultData.Fields[1].Value) + } +} + +func TestEntryUpdate_optimistic_locking(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + entry := &Entry{ + Type: TypeCredential, + Title: "Original", + VaultData: &VaultData{Title: "Original", Type: "credential"}, + } + EntryCreate(db, vk, entry) + + // Update with correct version + entry.Title = "Updated" + entry.VaultData.Title = "Updated" + if err := EntryUpdate(db, vk, entry); err != nil { + t.Fatalf("update: %v", err) + } + if entry.Version != 2 { + t.Errorf("version after update should be 2, got %d", entry.Version) + } + + // Update with stale version should fail + entry.Version = 1 // stale + entry.Title = "Stale" + err := EntryUpdate(db, vk, entry) + if err != ErrVersionConflict { + t.Errorf("expected ErrVersionConflict, got %v", err) + } +} + +func TestEntryDelete_soft_delete(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + entry := &Entry{ + Type: TypeCredential, + Title: "ToDelete", + VaultData: &VaultData{Title: "ToDelete", Type: "credential"}, + } + EntryCreate(db, vk, entry) + + if err := EntryDelete(db, int64(entry.EntryID)); err != nil { + t.Fatal(err) + } + + // Should not appear in list + entries, err := EntryList(db, vk, nil) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.EntryID == entry.EntryID { + t.Error("deleted entry should not appear in list") + } + } + + // Direct get should still work but have DeletedAt set + got, err := EntryGet(db, vk, int64(entry.EntryID)) + if err != nil { + t.Fatal(err) + } + if got.DeletedAt == nil { + t.Error("deleted entry should have DeletedAt set") + } +} + +func TestEntryList_filters_by_parent(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + folder := &Entry{Type: TypeFolder, Title: "Work", VaultData: &VaultData{Title: "Work", Type: "folder"}} + EntryCreate(db, vk, folder) + + child := &Entry{ + Type: TypeCredential, + Title: "WorkGitHub", + ParentID: folder.EntryID, + VaultData: &VaultData{Title: "WorkGitHub", Type: "credential"}, + } + EntryCreate(db, vk, child) + + orphan := &Entry{ + Type: TypeCredential, + Title: "Personal", + VaultData: &VaultData{Title: "Personal", Type: "credential"}, + } + EntryCreate(db, vk, orphan) + + parentID := int64(folder.EntryID) + children, err := EntryList(db, vk, &parentID) + if err != nil { + t.Fatal(err) + } + if len(children) != 1 { + t.Fatalf("expected 1 child, got %d", len(children)) + } + if children[0].Title != "WorkGitHub" { + t.Errorf("child title = %q", children[0].Title) + } +} + +func TestEntrySearchFuzzy(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + for _, title := range []string{"GitHub", "GitLab", "AWS Console"} { + EntryCreate(db, vk, &Entry{ + Type: TypeCredential, + Title: title, + VaultData: &VaultData{Title: title, Type: "credential"}, + }) + } + + results, err := EntrySearchFuzzy(db, vk, "Git") + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Errorf("search for 'Git' should return 2 results, got %d", len(results)) + } +} + +func TestEntryCount(t *testing.T) { + db := testDB(t) + vk := testVaultKey() + + count, _ := EntryCount(db) + if count != 0 { + t.Errorf("empty db should have 0 entries, got %d", count) + } + + EntryCreate(db, vk, &Entry{ + Type: TypeCredential, Title: "One", + VaultData: &VaultData{Title: "One", Type: "credential"}, + }) + EntryCreate(db, vk, &Entry{ + Type: TypeCredential, Title: "Two", + VaultData: &VaultData{Title: "Two", Type: "credential"}, + }) + + count, _ = EntryCount(db) + if count != 2 { + t.Errorf("expected 2 entries, got %d", count) + } +} + +func TestAuditLog_write_and_read(t *testing.T) { + db := testDB(t) + + AuditLog(db, &AuditEvent{ + Action: ActionCreate, + Actor: ActorWeb, + Title: "GitHub", + IPAddr: "127.0.0.1", + }) + AuditLog(db, &AuditEvent{ + Action: ActionRead, + Actor: ActorAgent, + Title: "GitHub", + IPAddr: "10.0.0.1", + }) + + events, err := AuditList(db, 10) + if err != nil { + t.Fatal(err) + } + if len(events) != 2 { + t.Fatalf("expected 2 audit events, got %d", len(events)) + } + // Both actions should be present (order depends on timestamp resolution) + actions := map[string]bool{} + for _, e := range events { + actions[e.Action] = true + } + if !actions[ActionCreate] { + t.Error("missing create action") + } + if !actions[ActionRead] { + t.Error("missing read action") + } +} + +func TestSessionCreate_and_Get(t *testing.T) { + db := testDB(t) + + session, err := SessionCreate(db, 3600, ActorWeb) + if err != nil { + t.Fatal(err) + } + if session.Token == "" { + t.Fatal("session token should not be empty") + } + + got, err := SessionGet(db, session.Token) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("session should exist") + } + if got.Actor != ActorWeb { + t.Errorf("actor = %q, want web", got.Actor) + } +} + +func TestSessionGet_expired(t *testing.T) { + db := testDB(t) + + // Create session with negative TTL (guaranteed expired) + session, _ := SessionCreate(db, -1, ActorWeb) + + got, err := SessionGet(db, session.Token) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Error("expired session should return nil") + } +} + +func TestWebAuthnCredential_store_and_list(t *testing.T) { + db := testDB(t) + + cred := &WebAuthnCredential{ + CredID: HexID(NewID()), + Name: "YubiKey", + PublicKey: []byte{1, 2, 3}, + CredentialID: []byte{4, 5, 6}, + PRFSalt: []byte{7, 8, 9}, + } + if err := StoreWebAuthnCredential(db, cred); err != nil { + t.Fatal(err) + } + + creds, err := GetWebAuthnCredentials(db) + if err != nil { + t.Fatal(err) + } + if len(creds) != 1 { + t.Fatalf("expected 1 credential, got %d", len(creds)) + } + if creds[0].Name != "YubiKey" { + t.Errorf("name = %q", creds[0].Name) + } + + count, _ := WebAuthnCredentialCount(db) + if count != 1 { + t.Errorf("count = %d, want 1", count) + } +} diff --git a/clavis/clavis-vault/lib/id_test.go b/clavis/clavis-vault/lib/id_test.go new file mode 100644 index 0000000..e262c8b --- /dev/null +++ b/clavis/clavis-vault/lib/id_test.go @@ -0,0 +1,45 @@ +package lib + +import ( + "testing" +) + +func TestNewID_unique(t *testing.T) { + ids := make(map[int64]bool) + for i := 0; i < 1000; i++ { + id := NewID() + if id <= 0 { + t.Fatalf("ID should be positive, got %d", id) + } + if ids[id] { + t.Fatalf("duplicate ID after %d iterations", i) + } + ids[id] = true + } +} + +func TestIDToHex_and_back(t *testing.T) { + id := NewID() + hex := IDToHex(id) + if len(hex) != 16 { + t.Fatalf("hex should be 16 chars, got %d: %s", len(hex), hex) + } + back, err := HexToID(hex) + if err != nil { + t.Fatal(err) + } + if back != id { + t.Errorf("roundtrip failed: %d -> %s -> %d", id, hex, back) + } +} + +func TestHexToID_invalid(t *testing.T) { + _, err := HexToID("short") + if err == nil { + t.Error("should reject short hex") + } + _, err = HexToID("zzzzzzzzzzzzzzzz") + if err == nil { + t.Error("should reject non-hex chars") + } +} diff --git a/clavis/clavis-vault/lib/importers_test.go b/clavis/clavis-vault/lib/importers_test.go new file mode 100644 index 0000000..cfa7d73 --- /dev/null +++ b/clavis/clavis-vault/lib/importers_test.go @@ -0,0 +1,158 @@ +package lib + +import ( + "testing" +) + +func TestDetectAndParse_ChromeCSV(t *testing.T) { + csv := "name,url,username,password\nGitHub,https://github.com,octocat,hunter2\nAWS,https://aws.amazon.com,admin,s3cret\n" + entries, ok := DetectAndParse([]byte(csv)) + if !ok { + t.Fatal("should detect Chrome CSV") + } + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } + if entries[0].Title != "GitHub" { + t.Errorf("title = %q", entries[0].Title) + } + if entries[0].Type != "credential" { + t.Errorf("type = %q", entries[0].Type) + } + if len(entries[0].URLs) == 0 || entries[0].URLs[0] != "https://github.com" { + t.Errorf("URL not parsed") + } +} + +func TestDetectAndParse_FirefoxCSV(t *testing.T) { + csv := "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged\nhttps://example.com,user@example.com,pass123,,,,,,1700000000000000\n" + entries, ok := DetectAndParse([]byte(csv)) + if !ok { + t.Fatal("should detect Firefox CSV") + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + // Firefox stores timePasswordChanged as microseconds + if entries[0].SourceModified != 1700000000 { + t.Errorf("SourceModified = %d, want 1700000000", entries[0].SourceModified) + } +} + +func TestDetectAndParse_BitwardenJSON(t *testing.T) { + json := `{"items":[{"name":"GitHub","type":1,"login":{"username":"octocat","password":"p@ss","uris":[{"uri":"https://github.com"}]},"revisionDate":"2024-01-15T10:00:00Z"}]}` + entries, ok := DetectAndParse([]byte(json)) + if !ok { + t.Fatal("should detect Bitwarden JSON") + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Title != "GitHub" { + t.Errorf("title = %q", entries[0].Title) + } + if entries[0].SourceModified == 0 { + t.Error("SourceModified should be parsed from revisionDate") + } +} + +func TestDetectAndParse_BitwardenCard(t *testing.T) { + json := `{"items":[{"name":"Amex","type":3,"card":{"cardholderName":"Johan","number":"378282246310005","code":"1234","expMonth":"09","expYear":"28"}}]}` + entries, ok := DetectAndParse([]byte(json)) + if !ok { + t.Fatal("should detect Bitwarden card") + } + if entries[0].Type != "card" { + t.Errorf("type = %q, want card", entries[0].Type) + } + // Card number and CVV should be auto-flagged L2 + for _, f := range entries[0].Fields { + if f.Label == "Number" && !f.L2 { + t.Error("card number should be L2") + } + if f.Label == "CVV" && !f.L2 { + t.Error("CVV should be L2") + } + } +} + +func TestDetectAndParse_unknown_format(t *testing.T) { + _, ok := DetectAndParse([]byte("this is not a known format")) + if ok { + t.Error("should not detect unknown format") + } +} + +func TestAutoL2Fields_labels(t *testing.T) { + entries := []VaultData{ + { + Title: "Bank", + Fields: []VaultField{ + {Label: "Username", Value: "user", Kind: "text"}, + {Label: "Card Number", Value: "4111111111111111", Kind: "text"}, + {Label: "CVV", Value: "123", Kind: "text"}, + {Label: "SSN", Value: "123-45-6789", Kind: "text"}, + {Label: "API Key", Value: "sk_live_abc", Kind: "text"}, + }, + }, + } + AutoL2Fields(entries) + + expectations := map[string]bool{ + "Username": false, + "Card Number": true, + "CVV": true, + "SSN": true, + "API Key": false, + } + for _, f := range entries[0].Fields { + want, ok := expectations[f.Label] + if !ok { + continue + } + if f.L2 != want { + t.Errorf("field %q: L2=%v, want %v", f.Label, f.L2, want) + } + } +} + +func TestAutoL2Fields_title_match_marks_all(t *testing.T) { + entries := []VaultData{ + { + Title: "Coinbase Wallet", + Fields: []VaultField{ + {Label: "Email", Value: "me@example.com", Kind: "text"}, + {Label: "Password", Value: "secret", Kind: "password"}, + }, + }, + } + AutoL2Fields(entries) + + for _, f := range entries[0].Fields { + if !f.L2 { + t.Errorf("field %q should be L2 (title matched crypto exchange)", f.Label) + } + } +} + +func TestAutoL2Fields_multilingual(t *testing.T) { + entries := []VaultData{ + { + Title: "Docs", + Fields: []VaultField{ + {Label: "Paspoort", Value: "NL12345", Kind: "text"}, // Dutch + {Label: "Führerschein", Value: "DE12345", Kind: "text"}, // German + {Label: "身份证", Value: "CN12345", Kind: "text"}, // Chinese + {Label: "パスポート", Value: "JP12345", Kind: "text"}, // Japanese + {Label: "PESEL", Value: "PL12345", Kind: "text"}, // Polish + }, + }, + } + AutoL2Fields(entries) + + for _, f := range entries[0].Fields { + if !f.L2 { + t.Errorf("field %q should be auto-detected as L2", f.Label) + } + } +} diff --git a/clavis/clavis-vault/lib/telemetry_test.go b/clavis/clavis-vault/lib/telemetry_test.go index db6598d..727d341 100644 --- a/clavis/clavis-vault/lib/telemetry_test.go +++ b/clavis/clavis-vault/lib/telemetry_test.go @@ -16,13 +16,14 @@ func TestCollectPayload(t *testing.T) { Host: "http://localhost:9999", Token: "test-token", DataDir: t.TempDir(), + Version: "test-1.0", } startTime := time.Now().Add(-5 * time.Minute) payload := CollectPayload(cfg, startTime) - if payload.Version == "" { - t.Error("version should not be empty") + if payload.Version != "test-1.0" { + t.Errorf("version = %q, want test-1.0", payload.Version) } if payload.Hostname == "" { t.Error("hostname should not be empty") @@ -43,7 +44,7 @@ func TestCollectPayload(t *testing.T) { t.Errorf("memory total should be > 0, got %d", payload.System.MemTotalMB) } - // Verify JSON roundtrip. + // JSON roundtrip data, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal: %v", err) @@ -53,7 +54,7 @@ func TestCollectPayload(t *testing.T) { t.Fatalf("unmarshal: %v", err) } if decoded.Hostname != payload.Hostname { - t.Errorf("hostname mismatch after roundtrip") + t.Error("hostname mismatch after roundtrip") } } @@ -77,12 +78,11 @@ func TestPostTelemetry(t *testing.T) { Host: server.URL, Token: "secret-token", DataDir: t.TempDir(), + Version: "test-post", } StartTelemetry(cfg) - - // Wait for the first post. - time.Sleep(500 * time.Millisecond) + time.Sleep(2 * time.Second) // CPU sampling takes 500ms, then POST mu.Lock() defer mu.Unlock() @@ -90,14 +90,16 @@ func TestPostTelemetry(t *testing.T) { if authHeader != "Bearer secret-token" { t.Errorf("expected Bearer secret-token, got %q", authHeader) } - if received.Version == "" { - t.Error("version should not be empty") + if received.Version != "test-post" { + t.Errorf("version = %q, want test-post", received.Version) + } + if received.Hostname == "" { + t.Error("hostname should not be empty in posted payload") } } -// Verify that StartTelemetry does nothing when disabled. func TestTelemetryDisabled(t *testing.T) { - // Should not panic or start goroutines. + // None of these should panic or start goroutines. StartTelemetry(TelemetryConfig{}) StartTelemetry(TelemetryConfig{FreqSeconds: 0, Host: "http://example.com"}) StartTelemetry(TelemetryConfig{FreqSeconds: 60, Host: ""}) diff --git a/clavis/clavis-vault/lib/tls.go b/clavis/clavis-vault/lib/tls.go index 591880d..d23f160 100644 --- a/clavis/clavis-vault/lib/tls.go +++ b/clavis/clavis-vault/lib/tls.go @@ -1,86 +1,89 @@ package lib import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" + "context" "fmt" - "math/big" - "net" + "log" + "net/http" "os" - "time" + + "github.com/caddyserver/certmagic" + "github.com/libdns/cloudflare" + "github.com/mholt/acmez/v3/acme" ) -// EnsureTLSCert checks if TLS cert/key exist at the configured paths. -// If not, generates a self-signed certificate valid for 10 years, -// covering localhost, 127.0.0.1, and all local LAN IPs. -func EnsureTLSCert(certPath, keyPath string) error { - _, certErr := os.Stat(certPath) - _, keyErr := os.Stat(keyPath) - if certErr == nil && keyErr == nil { - return nil // both exist - } - - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return fmt.Errorf("generate key: %w", err) - } - - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return fmt.Errorf("serial: %w", err) - } - - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{Organization: []string{"Clavitor"}, CommonName: "clavitor"}, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - - DNSNames: []string{"localhost", "clavitor.local"}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - } - - // Add all local interface IPs so LAN access works - if addrs, err := net.InterfaceAddrs(); err == nil { - for _, a := range addrs { - if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { - tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP) - } - } - } - - certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) - if err != nil { - return fmt.Errorf("create cert: %w", err) - } - - certFile, err := os.Create(certPath) - if err != nil { - return fmt.Errorf("write cert: %w", err) - } - defer certFile.Close() - if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { - return fmt.Errorf("encode cert: %w", err) - } - - keyDER, err := x509.MarshalECPrivateKey(key) - if err != nil { - return fmt.Errorf("marshal key: %w", err) - } - keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) - if err != nil { - return fmt.Errorf("write key: %w", err) - } - defer keyFile.Close() - if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { - return fmt.Errorf("encode key: %w", err) - } - - return nil +// TLSConfig holds configuration for automatic TLS via Let's Encrypt DNS-01. +type TLSConfig struct { + Domain string // e.g. "use1.clavitor.ai" + CFToken string // Cloudflare API token for DNS-01 challenge + DataDir string // directory to store certs + Email string // ACME account email +} + +// LoadTLSConfig reads TLS configuration from environment variables. +func LoadTLSConfig() TLSConfig { + return TLSConfig{ + Domain: os.Getenv("TLS_DOMAIN"), + CFToken: os.Getenv("CF_API_TOKEN"), + DataDir: os.Getenv("TLS_CERT_DIR"), + Email: os.Getenv("TLS_EMAIL"), + } +} + +// ListenAndServeTLS starts an HTTPS server on the given address using +// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge. +// Falls back to plain HTTP if TLS is not configured. +func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error { + if cfg.Domain == "" || cfg.CFToken == "" { + log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP") + return http.ListenAndServe(addr, handler) + } + + if cfg.DataDir == "" { + cfg.DataDir = "/opt/clavitor/certs" + } + if cfg.Email == "" { + cfg.Email = "ops@clavitor.ai" + } + + // Configure certmagic + certmagic.DefaultACME.Agreed = true + certmagic.DefaultACME.Email = cfg.Email + certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: &cloudflare.Provider{ + APIToken: cfg.CFToken, + }, + }, + } + + magic := certmagic.NewDefault() + magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir} + + // Obtain/renew cert + ctx := context.Background() + if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil { + return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err) + } + + tlsConfig := magic.TLSConfig() + tlsConfig.NextProtos = []string{"h2", "http/1.1"} + + server := &http.Server{ + Addr: addr, + Handler: handler, + TLSConfig: tlsConfig, + } + + log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain) + // TLS certs are managed by certmagic, pass empty cert/key paths + return server.ListenAndServeTLS("", "") +} + +// ChallengeError wraps ACME errors with actionable context. +func ChallengeError(err error) string { + if acmeErr, ok := err.(acme.Problem); ok { + return fmt.Sprintf("ACME error: %s (type: %s)", acmeErr.Detail, acmeErr.Type) + } + return err.Error() } diff --git a/clavis/clavis-vault/lib/types.go b/clavis/clavis-vault/lib/types.go index 95e1225..2ed9db9 100644 --- a/clavis/clavis-vault/lib/types.go +++ b/clavis/clavis-vault/lib/types.go @@ -78,7 +78,7 @@ type Session struct { Token string `json:"token"` CreatedAt int64 `json:"created_at"` ExpiresAt int64 `json:"expires_at"` - Actor string `json:"actor"` // web|extension|mcp + Actor string `json:"actor"` // web|extension|agent } // AuditEvent represents a security audit log entry. @@ -87,23 +87,11 @@ type AuditEvent struct { EntryID HexID `json:"entry_id,omitempty"` Title string `json:"title,omitempty"` // snapshot of entry title Action string `json:"action"` // read|fill|ai_read|create|update|delete|import|export - Actor string `json:"actor"` // web|extension|mcp + Actor string `json:"actor"` // web|extension|agent IPAddr string `json:"ip_addr,omitempty"` CreatedAt int64 `json:"created_at"` } -// MCPToken represents a scoped MCP access token. -type MCPToken struct { - ID HexID `json:"id"` - Label string `json:"label"` - Token string `json:"token"` - EntryIDs []HexID `json:"entry_ids,omitempty"` - ReadOnly bool `json:"read_only"` - ExpiresAt int64 `json:"expires_at"` - LastUsed int64 `json:"last_used"` - CreatedAt int64 `json:"created_at"` -} - // WebAuthnCredential stores a registered WebAuthn credential. type WebAuthnCredential struct { CredID HexID `json:"cred_id"` @@ -143,7 +131,6 @@ const ( const ( ActorWeb = "web" ActorExtension = "extension" - ActorMCP = "mcp" ActorAgent = "agent" ) diff --git a/clavitor.com/CLAUDE.md b/clavitor.com/CLAUDE.md index 076f1fb..e201665 100644 --- a/clavitor.com/CLAUDE.md +++ b/clavitor.com/CLAUDE.md @@ -1,5 +1,24 @@ # Clavitor Website — clavitor.com +## Foundation First — No Mediocrity. Ever. +The rule is simple: do it right, or say something. + +Johan is an architect. Architects do not patch cracks in a bad foundation — they rebuild. Every agent on this team operates the same way. + +**What this means in practice:** +- If you need three fixes for one problem, stop. Something fundamental is wrong. Name it, surface it — we fix that, not the symptom. +- If the code is spaghetti, say so. Do not add another workaround. The workaround is the problem now. +- Quick fixes are not fixes. A "temporary" hack that ships is permanent. If it is not the right solution, it is the wrong solution. +- Foundation > speed. A solid base makes everything downstream easy. A shaky base makes everything downstream a nightmare. We build bases. + +**The restart rule:** When the foundation is wrong: start over. Not "refactor slightly." Not "add an abstraction layer on top." Start over. This applies to code, infrastructure, design, encryption schemes, and written work alike. + +**Q&D is research, not output:** Exploratory/throwaway work has its place — but it stays in research. Nothing Q&D ships. Nothing Q&D becomes the production path. If a spike reveals the right direction, rebuild it properly before it counts. + +**When you hit a bad foundation:** 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. These rules apply to you now. + ## Architecture - Go web server (`main.go`) with `go:embed` for templates, CSS, SVGs, PNGs - Templates in `templates/*.tmpl`, single CSS in `clavitor.css` @@ -17,7 +36,7 @@ make deploy-dev # same thing ``` Dev runs on forge (localhost). `dev.clavitor.ai` DNS points to home IP. -### Prod (Zürich — zurich.inou.com — clavitor.ai) +### Prod (Zürich — clavitor.ai — clavitor.ai) ``` make deploy-prod # cross-compile amd64, scp to Zürich, restart systemd ``` @@ -31,7 +50,7 @@ make setup-prod # creates /opt/clavitor-web, systemd service, uploads binary+ Then manually update `/etc/caddy/Caddyfile` to reverse_proxy. ### SSH -- Prod: `ssh root@zurich.inou.com` +- Prod: `ssh root@clavitor.ai` - Tailscale: `zurich` (100.70.148.118) — SSH may be blocked via Tailscale ## Build & Run diff --git a/clavitor.com/Makefile b/clavitor.com/Makefile index b62dfd1..3233a1d 100644 --- a/clavitor.com/Makefile +++ b/clavitor.com/Makefile @@ -1,5 +1,5 @@ BINARY = clavitor-web -PROD_HOST = root@zurich.inou.com +PROD_HOST = root@clavitor.ai PROD_DIR = /opt/clavitor-web PORT = 8099 @@ -28,13 +28,18 @@ build-prod: GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o $(BINARY)-linux-amd64 . # Deploy to prod (Zürich — clavitor.ai) +SSH_MUX = -o ControlMaster=auto -o ControlPath=/tmp/clavitor-deploy-%r@%h -o ControlPersist=30 + deploy-prod: build-prod - scp $(BINARY)-linux-amd64 $(PROD_HOST):$(PROD_DIR)/ - ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"INSERT INTO maintenance (start_at, reason, started_by, ended_by) VALUES (strftime('%s','now'), 'deploy', 'makefile', '')\"" - ssh $(PROD_HOST) "cd $(PROD_DIR) && mv $(BINARY)-linux-amd64 $(BINARY) && systemctl restart clavitor-web" - sleep 5 - ssh $(PROD_HOST) "sqlite3 $(PROD_DIR)/clavitor.db \"UPDATE maintenance SET end_at = strftime('%s','now'), ended_by = 'makefile' WHERE end_at IS NULL\"" - @echo "✓ prod deployed → https://clavitor.ai" + @echo "$$(date +%H:%M:%S) upload..." + scp $(SSH_MUX) $(BINARY)-linux-amd64 $(PROD_HOST):$(PROD_DIR)/ + @echo "$$(date +%H:%M:%S) maintenance on + restart + wait + maintenance off..." + ssh $(SSH_MUX) $(PROD_HOST) "\ + sqlite3 $(PROD_DIR)/clavitor.db \"INSERT INTO maintenance (start_at, reason, started_by, ended_by) VALUES (strftime('%s','now'), 'deploy', 'makefile', '')\" && \ + cd $(PROD_DIR) && mv $(BINARY)-linux-amd64 $(BINARY) && systemctl restart clavitor-web && \ + sleep 5 && \ + sqlite3 $(PROD_DIR)/clavitor.db \"UPDATE maintenance SET end_at = strftime('%s','now'), ended_by = 'makefile' WHERE end_at IS NULL\"" + @echo "$$(date +%H:%M:%S) ✓ prod deployed → https://clavitor.ai" # Pull prod DB backup locally backup-prod: diff --git a/clavitor.com/clavitor-web-linux-amd64 b/clavitor.com/clavitor-web-linux-amd64 index 68079dc..5f03778 100755 Binary files a/clavitor.com/clavitor-web-linux-amd64 and b/clavitor.com/clavitor-web-linux-amd64 differ diff --git a/clavitor.com/clavitor.css b/clavitor.com/clavitor.css index db7aced..28ab734 100644 --- a/clavitor.com/clavitor.css +++ b/clavitor.com/clavitor.css @@ -53,10 +53,10 @@ img, svg { display: block; max-width: 100%; } /* === LAYOUT === */ .container { max-width: var(--width); margin: 0 auto; padding: 0 var(--pad); } -.section { padding-top: 80px; padding-bottom: 80px; } +.section { padding-top: 48px; padding-bottom: 48px; } .narrow { max-width: 800px; margin: 0 auto; } .prose-width { max-width: 720px; } -hr.divider { border: none; border-top: 1px solid var(--border); } +hr.divider { border: none; border-top: 1px solid var(--border); margin: 0 auto; max-width: var(--width); padding: 0 var(--pad); } /* === TYPOGRAPHY === */ h1 { font-size: clamp(2.25rem, 4vw, 3.5rem); font-weight: 700; line-height: 1.1; letter-spacing: -0.022em; color: var(--text); } @@ -88,14 +88,26 @@ code { font-size: 0.875em; } .logo-lockup-nav { transform: scale(0.65); transform-origin: left center; } /* === NAV === */ -.nav { position: fixed; top: 0; width: 100%; z-index: 50; background: rgba(255,255,255,0.92); backdrop-filter: blur(12px); } -.nav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); } +.nav { position: fixed; top: 0; width: 100%; z-index: 50; background: rgba(255,255,255,0.95); backdrop-filter: blur(16px); } +.nav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); margin-bottom: 12px; box-shadow: 0 8px 16px rgba(255,255,255,0.9); } .nav-logo { line-height: 1; } .nav-links { display: flex; align-items: center; gap: 1.5rem; font-size: 0.875rem; } .nav-link { color: var(--text-secondary); font-weight: 500; transition: color 100ms ease; } .nav-link:hover { color: var(--text); } -.nav-link.active { color: var(--text); font-weight: 600; display: flex; align-items: center; gap: 0.375rem; } -.nav-link.active::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--brand-black); animation: pulse 2s ease-in-out infinite; } +.nav-link.active { color: var(--text); font-weight: 600; } +.nav-hamburger { display: none; background: none; border: none; cursor: pointer; padding: 8px; } +.nav-hamburger span { display: block; width: 20px; height: 2px; background: var(--text); margin: 4px 0; } +.nav-link.red { color: var(--brand-red); } +.nav-link.red:hover { color: var(--brand-red-dark); } +.nav-link.disabled { opacity: 0.4; cursor: default; } +.nav-dropdown { position: relative; } +.nav-dropdown-trigger { cursor: default; display: flex; align-items: center; gap: 4px; } +.nav-dropdown-trigger::after { content: ''; display: inline-block; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 4px solid currentColor; opacity: 0.5; } +.nav-dropdown-menu { display: none; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); background: #fff; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 0; min-width: 180px; box-shadow: 0 4px 16px rgba(0,0,0,0.08); z-index: 60; padding-top: 12px; } +.nav-dropdown-menu::before { content: ''; position: absolute; top: -8px; left: 0; right: 0; height: 8px; } +.nav-dropdown:hover .nav-dropdown-menu { display: block; } +.nav-dropdown-item { display: block; padding: 6px 16px; font-size: 0.825rem; color: var(--text-secondary); font-weight: 500; white-space: nowrap; } +.nav-dropdown-item:hover { color: var(--text); background: var(--surface); } /* === BUTTONS === */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--font-sans); font-size: 0.875rem; font-weight: 600; padding: 0.625rem 1.25rem; border-radius: var(--radius-sm); border: 1px solid transparent; cursor: pointer; transition: all 100ms ease; text-align: center; text-decoration: none; } @@ -261,8 +273,14 @@ code { font-size: 0.875em; } .grid-3-equal { grid-template-columns: 1fr; } .grid-4-equal { grid-template-columns: repeat(2, 1fr); } #dc-grid { flex-direction: column; } - .nav-links { gap: 0.75rem; font-size: 0.75rem; } - .section { padding-top: 48px; padding-bottom: 48px; } + .nav-links { display: none; position: fixed; top: 64px; left: 0; right: 0; bottom: 0; background: #fff; flex-direction: column; align-items: flex-start; padding: 24px; gap: 1.25rem; font-size: 1rem; overflow-y: auto; } + .nav-links.open { display: flex; } + .nav-hamburger { display: flex; } + .nav-dropdown-menu { position: static; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; } + .nav-dropdown:hover .nav-dropdown-menu { display: none; } + .nav-dropdown.open .nav-dropdown-menu { display: block; } + .nav-dropdown-trigger::after { display: inline-block; } + .section { padding-top: 32px; padding-bottom: 32px; } .glass-grid { grid-template-columns: 1fr; } } @@ -270,31 +288,29 @@ code { font-size: 0.875em; } .glass-grid { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 1px; - background: var(--border); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; + gap: 12px; } .glass-pop { - background: var(--bg); + background: var(--surface); padding: 20px; - transition: background 0.15s; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: box-shadow 0.15s, border-color 0.15s; } -.glass-pop:hover { background: var(--surface); } -.glass-live { border-left: 3px solid var(--brand-red); } -.glass-planned { border-left: 3px solid var(--border); } +.glass-pop:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-color: #ccc; } +.glass-live { } +.glass-planned { opacity: 0.6; } .glass-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } -.glass-city { - font-weight: 700; - font-size: 0.9rem; - color: var(--text); -} +.pop-city { font-weight: 600; font-size: 0.95rem; color: var(--text); } +.pop-country { font-size: 0.72rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; } +.glass-header .pop-country { margin-left: 0; margin-top: 2px; } +.st-header .pop-country { margin-left: 8px; } +.glass-city { font-weight: 600; font-size: 0.95rem; color: var(--text); } .glass-status { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; @@ -304,22 +320,21 @@ code { font-size: 0.875em; } padding: 2px 8px; border-radius: 3px; } -.glass-status-live { background: var(--brand-red); color: #fff; } +.glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; } .glass-status-planned { background: var(--surface); color: var(--muted); } +.glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; } +.glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; } +.glass-latency-left { display: flex; flex-direction: column; } +.glass-latency-title { font-size: 0.72rem; color: var(--muted); } +.glass-latency-hint { font-size: 0.72rem; color: var(--muted); } +.glass-latency-hero { font-size: 1.5rem; font-weight: 700; line-height: 1; } .glass-details { display: flex; flex-direction: column; gap: 6px; } -.glass-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.78rem; -} -.glass-key { color: var(--muted); } -.glass-val { color: var(--text); } -.glass-val.mono { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; } +.glass-row { display: flex; justify-content: space-between; align-items: center; font-size: 0.75rem; color: var(--muted); } +.glass-val.mono { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); } .glass-muted { color: var(--muted); font-style: italic; } .glass-fast { color: #16a34a; font-weight: 600; } -.glass-ok { color: #ca8a04; font-weight: 600; } -.glass-slow { color: var(--brand-red); font-weight: 600; } +.glass-ok { color: var(--text); font-weight: 600; } +.glass-slow { color: #dc2626; font-weight: 600; } @media (max-width: 1024px) { .glass-grid { grid-template-columns: repeat(3, 1fr); } diff --git a/clavitor.com/main.go b/clavitor.com/main.go index 63d498c..025754e 100644 --- a/clavitor.com/main.go +++ b/clavitor.com/main.go @@ -29,6 +29,23 @@ var templates *template.Template var devMode bool var db *sql.DB +var countryNames = map[string]string{ + "US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia", + "BR": "Brazil", "CL": "Chile", "GB": "United Kingdom", "CH": "Switzerland", + "ES": "Spain", "SE": "Sweden", "TR": "Turkey", "AE": "UAE", + "NG": "Nigeria", "KE": "Kenya", "ZA": "South Africa", "IN": "India", + "SG": "Singapore", "AU": "Australia", "JP": "Japan", "KR": "South Korea", + "HK": "Hong Kong", "NZ": "New Zealand", "KZ": "Kazakhstan", "BD": "Bangladesh", + "PH": "Philippines", "TH": "Thailand", "TW": "Taiwan", "ID": "Indonesia", +} + +func countryName(code string) string { + if name, ok := countryNames[code]; ok { + return name + } + return code +} + type Pop struct { PopID int City string @@ -36,10 +53,11 @@ type Pop struct { Lat float64 Lon float64 RegionName string - IP string - DNS string - Status string - Provider string + IP string + DNS string + Status string + Provider string + CountryFull string } type PageData struct { @@ -73,6 +91,7 @@ func loadPops() []Pop { log.Printf("pops scan error: %v", err) continue } + p.CountryFull = countryName(p.Country) pops = append(pops, p) } return pops @@ -158,7 +177,9 @@ func main() { render(w, PageData{Page: "install", Title: "Self-host — clavitor", Desc: "Self-host clavitor in 30 seconds. One binary, no dependencies.", ActiveNav: "install"}) }) http.HandleFunc("/pricing", func(w http.ResponseWriter, r *http.Request) { - render(w, PageData{Page: "pricing", Title: "Pricing — clavitor", Desc: "Free self-hosted or $12/year hosted (launch price). No tiers, no per-seat, no contact sales.", ActiveNav: "pricing"}) + data := PageData{Page: "pricing", Title: "Pricing — clavitor", Desc: "Free self-hosted or $12/year hosted (launch price). No tiers, no per-seat, no contact sales.", ActiveNav: "pricing"} + data.Pops = loadPops() + render(w, data) }) http.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) { render(w, PageData{Page: "privacy", Title: "Privacy Policy — clavitor"}) @@ -169,6 +190,18 @@ func main() { http.HandleFunc("/sources", func(w http.ResponseWriter, r *http.Request) { render(w, PageData{Page: "sources", Title: "Sources — clavitor"}) }) + http.HandleFunc("/integrations/claude-code", func(w http.ResponseWriter, r *http.Request) { + render(w, PageData{Page: "claude-code", Title: "Clavitor + Claude Code — Secure credential access", ActiveNav: "integrations"}) + }) + http.HandleFunc("/integrations/codex", func(w http.ResponseWriter, r *http.Request) { + render(w, PageData{Page: "codex", Title: "Clavitor + OpenAI Codex — CLI integration", ActiveNav: "integrations"}) + }) + http.HandleFunc("/integrations/openclaw", func(w http.ResponseWriter, r *http.Request) { + render(w, PageData{Page: "openclaw", Title: "Clavitor + OpenClaw — Multi-agent credentials", ActiveNav: "integrations"}) + }) + http.HandleFunc("/integrations/openclaw/cn", func(w http.ResponseWriter, r *http.Request) { + render(w, PageData{Page: "openclaw-cn", Title: "Clavitor + OpenClaw — AI 智能体凭据管理", ActiveNav: "integrations"}) + }) // Notify — sends signup interest email http.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { @@ -550,7 +583,7 @@ func main() { // Public status page http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - data := PageData{Page: "status", Title: "Status — clavitor"} + data := PageData{Page: "status", Title: "Status — clavitor", ActiveNav: "status"} data.Pops = loadPops() render(w, data) }) @@ -608,13 +641,11 @@ func main() { health := "planned" if p.Status == "live" { - health = "unknown" + health = "down" if ts, ok := lastSeen[id]; ok { age := now.Unix() - ts if age < 150 { health = "operational" - } else if age < 600 { - if inMaintenance { health = "maintenance" } else { health = "degraded" } } else { if inMaintenance { health = "maintenance" } else { health = "down" } } @@ -632,7 +663,7 @@ func main() { } nodes = append(nodes, NodeStatus{ - ID: id, City: p.City, Country: p.Country, + ID: id, City: p.City, Country: countryName(p.Country), Region: p.RegionName, Status: p.Status, Health: health, Uptime: uptime90, }) @@ -657,7 +688,9 @@ func main() { } overall := "All Systems Operational" - if !allOperational { + if inMaintenance { + overall = "Scheduled Maintenance" + } else if !allOperational { overall = "Some Systems Degraded" } @@ -730,15 +763,46 @@ func main() { }) http.HandleFunc("/glass", func(w http.ResponseWriter, r *http.Request) { - data := PageData{Page: "glass", Title: "Looking Glass — clavitor"} + data := PageData{Page: "glass", Title: "Looking Glass — clavitor", ActiveNav: "glass"} data.Pops = loadPops() render(w, data) }) + // SEO + http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("User-agent: *\nAllow: /\nDisallow: /noc\nDisallow: /telemetry\n\nSitemap: https://clavitor.ai/sitemap.xml\n")) + }) + http.HandleFunc("/sitemap.xml", func(w http.ResponseWriter, r *http.Request) { + pages := []string{ + "/", "/hosted", "/pricing", "/install", "/status", "/glass", + "/privacy", "/terms", "/sources", "/signup", + "/integrations/claude-code", "/integrations/codex", + "/integrations/openclaw", "/integrations/openclaw/cn", + } + w.Header().Set("Content-Type", "application/xml") + xml := `` + for _, p := range pages { + xml += `https://clavitor.ai` + p + `` + } + xml += `` + w.Write([]byte(xml)) + }) + http.HandleFunc("/security.txt", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n")) + }) + http.HandleFunc("/.well-known/security.txt", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("Contact: mailto:security@clavitor.ai\nPreferred-Languages: en\n")) + }) + // Catch-all: index page at "/" or static files or .html redirects http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - render(w, PageData{Page: "index", Title: "clavitor — AI-native password manager", Desc: "Field-level encryption for password managers that live alongside AI assistants. Your AI gets what it needs. Your secrets stay yours."}) + data := PageData{Page: "index", Title: "clavitor — AI-native password manager", Desc: "Field-level encryption for password managers that live alongside AI assistants. Your AI gets what it needs. Your secrets stay yours."} + data.Pops = loadPops() + render(w, data) return } // Redirect old .html URLs to clean paths diff --git a/clavitor.com/templates/base.tmpl b/clavitor.com/templates/base.tmpl index 498d8bb..1208da3 100644 --- a/clavitor.com/templates/base.tmpl +++ b/clavitor.com/templates/base.tmpl @@ -18,11 +18,27 @@