diff --git a/clavis/clavis-vault/api/integration_test.go b/clavis/clavis-vault/api/integration_test.go index 08014d6..83a331b 100644 --- a/clavis/clavis-vault/api/integration_test.go +++ b/clavis/clavis-vault/api/integration_test.go @@ -122,6 +122,37 @@ func (c *tc) reqNoAuth(method, path string, body any) *http.Response { return resp } +// reqAgent sends a request with CVT wire token authentication (agent). +// The wireToken is a type 0x00 CVT token containing L1 + agent_id. +func (c *tc) reqAgent(method, path string, body any, wireToken string) *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") + } + req.Header.Set("Authorization", "Bearer "+wireToken) + resp, err := c.srv.Client().Do(req) + if err != nil { + c.t.Fatalf("reqAgent %s %s: %v", method, path, err) + } + return resp +} + +// mintWireToken creates a CVT wire token (type 0x00) for agent authentication. +func (c *tc) mintWireToken(agentID []byte) string { + c.t.Helper() + token, err := lib.MintWireToken(fakeL0(), fakeL1(), agentID) + if err != nil { + c.t.Fatalf("mintWireToken: %v", err) + } + return token +} + // must asserts status code and returns parsed JSON object. func (c *tc) must(resp *http.Response, wantStatus int) map[string]any { c.t.Helper() @@ -323,29 +354,195 @@ func TestAuditLog(t *testing.T) { // --------------------------------------------------------------------------- func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) { - // AGENT CREDENTIALS NOW GENERATED CLIENT-SIDE - // Tests requiring agent tokens need client-side credential generation - t.Skip("Agent credentials now client-side - test needs rewrite") + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create entries with different scopes + c.must(c.req("POST", "/api/entries", credentialEntry("Work Entry", "user", "pass", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("Personal Entry", "user", "pass", nil)), 201) + + // Create agent with specific scope (work only) + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "Work Agent", + "all_access": false, + "scope_whitelist": []string{"work"}, + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent with work scope should see entries (scope filtering happens at data level) + // With empty scope_whitelist, agent sees nothing + // With ["work"], agent sees work entries + list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) + // Agent with work scope should see entries (default scope is "0000" which is owner-only) + // Since entries have no explicit scope, they default to "0000" (owner-only) + // So agent should see 0 entries + if len(list) != 0 { + t.Fatalf("agent with work scope should see 0 owner-scoped entries, got %d", len(list)) + } } func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) { - t.Skip("Agent credentials now client-side - test needs rewrite") + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create an entry + entry := c.must(c.req("POST", "/api/entries", credentialEntry("Test Entry", "user", "pass", nil)), 201) + entryID := entry["entry_id"].(string) + + // Create agent with no scope whitelist (cannot access owner-only entries) + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "Limited Agent", + "all_access": false, + "scope_whitelist": []string{}, // Empty whitelist + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent should get 403 when trying to access owner-only entry + resp := c.reqAgent("GET", "/api/entries/"+entryID, nil, wireToken) + if resp.StatusCode != 403 { + t.Fatalf("agent without scope access should get 403, got %d", resp.StatusCode) + } + resp.Body.Close() } func TestScopedAccess_all_access_sees_everything(t *testing.T) { - t.Skip("Agent credentials now client-side - test needs rewrite") + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create entries + c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user", "pass", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user", "pass", nil)), 201) + + // Create agent with all_access flag + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "All Access Agent", + "all_access": true, + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent with all_access should see all entries + list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) + if len(list) != 2 { + t.Fatalf("all_access agent should see 2 entries, got %d", len(list)) + } } func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) { - t.Skip("Agent credentials now client-side - test needs rewrite") + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create agent + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "Regular Agent", + "all_access": false, + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent should get 403 when trying to create another agent + resp := c.reqAgent("POST", "/api/agents", map[string]any{ + "name": "New Agent", + "all_access": false, + }, wireToken) + if resp.StatusCode != 403 { + t.Fatalf("agent should not be able to create agents, expected 403 got %d", resp.StatusCode) + } + resp.Body.Close() + + // Agent should get 403 when trying to list agents + resp = c.reqAgent("GET", "/api/agents", nil, wireToken) + if resp.StatusCode != 403 { + t.Fatalf("agent should not be able to list agents, expected 403 got %d", resp.StatusCode) + } + resp.Body.Close() } -func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) { - t.Skip("Agent credentials now client-side - test needs rewrite") +func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) { + // Agents cannot create entries with system types (agent, scope) + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create agent + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "Regular Agent", + "all_access": false, + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent should get 403 when trying to create entry with type=agent + resp := c.reqAgent("POST", "/api/entries", map[string]any{ + "title": "Fake Agent", + "type": "agent", + "data": map[string]any{ + "title": "Fake Agent", + "type": "agent", + }, + }, wireToken) + if resp.StatusCode != 403 { + t.Fatalf("agent should not be able to create agent-type entries, expected 403 got %d", resp.StatusCode) + } + resp.Body.Close() + + // Agent should get 403 when trying to create entry with type=scope + resp = c.reqAgent("POST", "/api/entries", map[string]any{ + "title": "Fake Scope", + "type": "scope", + "data": map[string]any{ + "title": "Fake Scope", + "type": "scope", + }, + }, wireToken) + if resp.StatusCode != 403 { + t.Fatalf("agent should not be able to create scope-type entries, expected 403 got %d", resp.StatusCode) + } + resp.Body.Close() } func TestScopedAccess_agent_entries_invisible(t *testing.T) { - t.Skip("Agent credentials now client-side - test needs rewrite") + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create entries + c.must(c.req("POST", "/api/entries", credentialEntry("Entry 1", "user1", "pass1", nil)), 201) + c.must(c.req("POST", "/api/entries", credentialEntry("Entry 2", "user2", "pass2", nil)), 201) + + // Create agent with limited scope + agentResp := c.must(c.req("POST", "/api/agents", map[string]any{ + "name": "Limited Agent", + "all_access": false, + "scope_whitelist": []string{"work"}, + }), 201) + agentIDHex := agentResp["agent_id"].(string) + agentID, _ := hex.DecodeString(agentIDHex) + + // Mint wire token for agent authentication + wireToken := c.mintWireToken(agentID) + + // Agent with work scope should not see owner-only entries (default scope "0000") + list := c.mustList(c.reqAgent("GET", "/api/entries", nil, wireToken), 200) + // Entries default to "0000" scope (owner-only), so agent sees nothing + if len(list) != 0 { + t.Fatalf("agent with work scope should see 0 owner-only entries, got %d", len(list)) + } } // --------------------------------------------------------------------------- @@ -424,9 +621,32 @@ func TestKeyLeak_L3_never_appears(t *testing.T) { func TestKeyLeak_agent_credential_is_opaque(t *testing.T) { // CREDENTIAL GENERATION IS NOW CLIENT-SIDE - // Server returns agent_id, web UI generates credential using crypto.js - // This test is obsolete - credential opacity is ensured by client-side implementation - t.Skip("Credential generation moved to client-side - server no longer returns credential") + // Server returns agent_id only, web UI generates credential using crypto.js + // This test verifies that the server response contains no key material + c := newTestClient(t) + c.must(c.req("POST", "/api/auth/setup", nil), 200) + + // Create agent - server returns only agent_id, no credential + body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{ + "name": "Leak Check Agent", + }), 201) + bodyStr := string(body) + + // Verify response contains agent_id but no credential token + if !strings.Contains(bodyStr, "agent_id") { + t.Fatal("agent creation response should contain agent_id") + } + + // Response should NOT contain cvt_ prefix (credential tokens start with cvt_) + if strings.Contains(bodyStr, "cvt_") { + t.Fatal("agent creation response should not contain cvt_ credential (now client-side)") + } + + // Verify no raw L2 in response + l2Hex := hex.EncodeToString(fakeL2()) + if strings.Contains(bodyStr, l2Hex) { + t.Fatal("L2 key (hex) found in agent creation response") + } } // --------------------------------------------------------------------------- diff --git a/clavis/clavis-vault/lib/cvt.go b/clavis/clavis-vault/lib/cvt.go index 6abc30f..d4d1ccd 100644 --- a/clavis/clavis-vault/lib/cvt.go +++ b/clavis/clavis-vault/lib/cvt.go @@ -21,7 +21,8 @@ import ( // (clavis-cli/src/cvt.c) and never decrypted by Go code β L2 is a hard veto // for the server. const ( - CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16) + CVTWireToken byte = 0x00 // Sent to vault: L1(8) + agent_id(16) + CVTCredentialType byte = 0x01 // Client credential: L2(16) + agent_id(16) + POP(4) ) const cvtPrefix = "cvt_" @@ -68,6 +69,36 @@ func ParseWireToken(token string) (l0, l1, agentID []byte, err error) { return l0, payload[0:8], payload[8:24], nil } +// MintCredential creates a type 0x01 client credential token (for testing). +// This simulates client-side credential generation that normally happens in browser/CLI. +// Payload: L2(16) + agent_id(16) + POP(4) = 36 bytes, encrypted with L0. +func MintCredential(l0, l2, agentID, pop []byte) (string, error) { + if len(l0) != 4 || len(l2) != 16 || len(agentID) != 16 || len(pop) != 4 { + return "", fmt.Errorf("bad lengths: l0=%d l2=%d agent_id=%d pop=%d", len(l0), len(l2), len(agentID), len(pop)) + } + payload := make([]byte, 36) + copy(payload[0:16], l2) + copy(payload[16:32], agentID) + copy(payload[32:36], pop) + return cvtEncode(CVTCredentialType, l0, payload) +} + +// ParseCredential decrypts a type 0x01 client credential token (for testing). +// Returns L0 (4 bytes), L2 (16 bytes), agent_id (16 bytes), and POP (4 bytes). +func ParseCredential(token string) (l0, l2, agentID, pop []byte, err error) { + typ, l0, payload, err := cvtDecode(token) + if err != nil { + return nil, nil, nil, nil, err + } + if typ != CVTCredentialType { + return nil, nil, nil, nil, ErrCVTBadType + } + if len(payload) != 36 { + return nil, nil, nil, nil, fmt.Errorf("credential payload: got %d bytes, want 36", len(payload)) + } + return l0, payload[0:16], payload[16:32], payload[32:36], nil +} + // --------------------------------------------------------------------------- // CVT envelope: type(1) + L0(4) + AES-GCM(derived(L0), payload) // --------------------------------------------------------------------------- diff --git a/clavis/clavis-vault/lib/cvt_test.go b/clavis/clavis-vault/lib/cvt_test.go index 7bac13b..6f0456d 100644 --- a/clavis/clavis-vault/lib/cvt_test.go +++ b/clavis/clavis-vault/lib/cvt_test.go @@ -59,3 +59,65 @@ func TestCVT_unique(t *testing.T) { t.Fatal("two tokens with same input should differ (random nonce)") } } + +// Test client credential (type 0x01) - used for agent testing +func TestMintCredential_roundtrip(t *testing.T) { + l0 := []byte{0x11, 0x22, 0x33, 0x44} + l2 := bytes.Repeat([]byte{0xAB}, 16) // 16 bytes test key + agentID := make([]byte, 16) + for i := range agentID { + agentID[i] = byte(0x40 + i) + } + pop := []byte{0x01, 0x02, 0x03, 0x04} + + token, err := MintCredential(l0, l2, agentID, pop) + if err != nil { + t.Fatalf("MintCredential: %v", err) + } + + gotL0, gotL2, gotAgentID, gotPOP, err := ParseCredential(token) + if err != nil { + t.Fatalf("ParseCredential: %v", err) + } + if !bytes.Equal(gotL0, l0) { + t.Fatalf("L0 mismatch: got %x, want %x", gotL0, l0) + } + if !bytes.Equal(gotL2, l2) { + t.Fatalf("L2 mismatch: got %x, want %x", gotL2, l2) + } + if !bytes.Equal(gotAgentID, agentID) { + t.Fatalf("agent_id mismatch: got %x, want %x", gotAgentID, agentID) + } + if !bytes.Equal(gotPOP, pop) { + t.Fatalf("POP mismatch: got %x, want %x", gotPOP, pop) + } +} + +func TestMintCredential_unique(t *testing.T) { + l0 := []byte{0x11, 0x22, 0x33, 0x44} + l2 := bytes.Repeat([]byte{0xCD}, 16) + agentID := make([]byte, 16) + pop := []byte{0x00, 0x00, 0x00, 0x00} + + t1, _ := MintCredential(l0, l2, agentID, pop) + t2, _ := MintCredential(l0, l2, agentID, pop) + if t1 == t2 { + t.Fatal("two credential tokens with same input should differ (random nonce)") + } +} + +func TestMintCredential_tamper_detection(t *testing.T) { + l0 := []byte{0x11, 0x22, 0x33, 0x44} + l2 := bytes.Repeat([]byte{0xEF}, 16) + agentID := make([]byte, 16) + pop := []byte{0xFF, 0xFF, 0xFF, 0xFF} + + token, _ := MintCredential(l0, l2, agentID, pop) + + // Flip a character in the middle + tampered := token[:10] + "X" + token[11:] + _, _, _, _, err := ParseCredential(tampered) + if err == nil { + t.Fatal("expected error on tampered credential token") + } +} diff --git a/clavitor.ai/clavitor.css b/clavitor.ai/clavitor.css index 67fb0c2..a9ab36c 100644 --- a/clavitor.ai/clavitor.css +++ b/clavitor.ai/clavitor.css @@ -133,6 +133,35 @@ code { font-size: 0.875em; } .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); } +/* === SPLIT LANGUAGE/CURRENCY SELECTORS === */ +.nav-dropdown--language, +.nav-dropdown--currency { display: inline-block; } +.nav-dropdown--language .nav-dropdown-trigger, +.nav-dropdown--currency .nav-dropdown-trigger { min-width: 60px; justify-content: center; } +.nav-dropdown--language + .nav-dropdown--currency { margin-left: 8px; } +@media (max-width: 768px) { + .nav-dropdown--language, + .nav-dropdown--currency { display: block; width: 100%; } + .nav-dropdown--language + .nav-dropdown--currency { margin-left: 0; margin-top: 0.5rem; } + .nav-dropdown--language .nav-dropdown-menu, + .nav-dropdown--currency .nav-dropdown-menu { position: static; right: auto; left: auto; transform: none; box-shadow: none; border: none; padding-left: 16px; min-width: 0; } +} + +/* === CURRENCY DROPDOWN SECTIONS === */ +.dropdown-section { + padding: 8px 16px; + font-size: 11px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + pointer-events: none; +} +.dropdown-divider { + border-top: 1px solid var(--border); + margin: 8px 0; +} + /* === 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; } .btn-primary { background: var(--brand-black); color: #ffffff; border-color: var(--brand-black); } diff --git a/clavitor.ai/templates/base.tmpl b/clavitor.ai/templates/base.tmpl index 6218b75..eb07db0 100644 --- a/clavitor.ai/templates/base.tmpl +++ b/clavitor.ai/templates/base.tmpl @@ -104,19 +104,20 @@ Pricing -