clavitor/clavis/clavis-vault/api/integration_test.go

651 lines
20 KiB
Go

package api
// Integration tests for the Clavitor vault API (rewrite).
//
// Uses a fake PRF to derive L0/L1/L2/L3 from a deterministic seed.
// Tests:
// - Full CRUD with legacy L1 bearer (vault owner)
// - CVT token agent auth with scoped access
// - Agent creation, scope assignment, deletion
// - Key leak detection: L1, L2, L3 never appear in API responses
// - Agents cannot manage other agents or scopes
// - all_access agents see everything
// - Owner-only (0000 scope) entries invisible to scoped agents
import (
"bytes"
"embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/johanj/clavitor/lib"
)
// ---------------------------------------------------------------------------
// Fake PRF — deterministic key hierarchy for testing
// ---------------------------------------------------------------------------
// fakePRF simulates the PRF output (32 bytes) from which all tiers derive.
// In production this comes from WebAuthn PRF extension.
var fakePRF = []byte{
0xAA, 0xBB, 0xCC, 0xDD, // L0 (4 bytes)
0x11, 0x22, 0x33, 0x44, // L1 = L0 + these 4 bytes (8 total)
0x55, 0x66, 0x77, 0x88, 0x99, 0xA0, 0xB1, 0xC2, // L2 = L1 + these 8 bytes (16 total)
0xD3, 0xE4, 0xF5, 0x06, 0x17, 0x28, 0x39, 0x4A, // L3 = L2 + these 8 bytes (24 total)
0x5B, 0x6C, 0x7D, 0x8E, 0x9F, 0xA0, 0xB1, 0xC2, // L3 continued (32 total)
}
func fakeL0() []byte { return fakePRF[:4] }
func fakeL1() []byte { return fakePRF[:8] }
func fakeL2() []byte { return fakePRF[:16] }
func fakeL3() []byte { return fakePRF[:32] }
// ---------------------------------------------------------------------------
// Test client
// ---------------------------------------------------------------------------
type tc struct {
srv *httptest.Server
bearer string // base64url-encoded L1 key (8 bytes) for legacy auth
t *testing.T
}
func newTestClient(t *testing.T) *tc {
t.Helper()
tmpDir := t.TempDir()
cfg := &lib.Config{Port: "0", DataDir: tmpDir, SessionTTL: 86400}
l1Raw := fakeL1()
bearer := base64.RawURLEncoding.EncodeToString(l1Raw)
// Create vault DB matching L1Middleware's naming convention
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)
}
db.Close()
var emptyFS embed.FS
srv := httptest.NewServer(NewRouter(cfg, emptyFS))
t.Cleanup(srv.Close)
return &tc{srv: srv, bearer: bearer, t: t}
}
// req sends a legacy L1 bearer request (vault owner).
func (c *tc) req(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")
}
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)
}
return resp
}
// reqAgent sends a request using a cvt_ wire token.
func (c *tc) reqAgent(credential, method, path string, body any) *http.Response {
c.t.Helper()
// Convert client credential (0x01) → wire token (0x00)
wire, _, err := lib.CredentialToWire(credential)
if err != nil {
c.t.Fatalf("CredentialToWire: %v", err)
}
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 "+wire)
resp, err := c.srv.Client().Do(req)
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()
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
}
// mustList asserts status and returns 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
}
// mustRaw asserts status and returns raw body bytes.
func (c *tc) mustRaw(resp *http.Response, wantStatus int) []byte {
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)
}
return body
}
// createAgent creates an agent via owner API, returns the credential token.
func (c *tc) createAgent(name string, allAccess bool) string {
c.t.Helper()
resp := c.req("POST", "/api/agents", map[string]any{
"name": name, "all_access": allAccess,
})
result := c.must(resp, 201)
cred, ok := result["credential"].(string)
if !ok || cred == "" {
c.t.Fatalf("createAgent: no credential in response: %v", result)
}
return cred
}
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
func credentialEntry(title, username, password string, urls []string) map[string]any {
return map[string]any{
"title": title, "type": "credential",
"data": map[string]any{
"title": title, "type": "credential",
"fields": []map[string]any{
{"label": "username", "value": username, "kind": "text"},
{"label": "password", "value": password, "kind": "password"},
},
"urls": urls,
},
}
}
// ---------------------------------------------------------------------------
// Health & Ping
// ---------------------------------------------------------------------------
func TestHealth(t *testing.T) {
c := newTestClient(t)
result := c.must(c.req("GET", "/health", nil), 200)
if result["status"] != "ok" {
t.Fatal("health not ok")
}
}
func TestPing(t *testing.T) {
c := newTestClient(t)
result := c.must(c.reqNoAuth("GET", "/ping", nil), 200)
if result["ok"] != true {
t.Fatal("ping not ok")
}
}
// ---------------------------------------------------------------------------
// Entry CRUD (legacy L1 bearer = vault owner)
// ---------------------------------------------------------------------------
func TestCreateEntry(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
entry := c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "pass123", []string{"https://github.com"})), 201)
if entry["title"] != "GitHub" {
t.Fatalf("title = %v", entry["title"])
}
if entry["scopes"] != "0000" {
t.Fatalf("default scope should be 0000, got %v", entry["scopes"])
}
}
func TestGetEntry(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
created := c.must(c.req("POST", "/api/entries", credentialEntry("Test", "user", "pass", nil)), 201)
id := created["entry_id"].(string)
got := c.must(c.req("GET", "/api/entries/"+id, nil), 200)
if got["title"] != "Test" {
t.Fatalf("title = %v", got["title"])
}
}
func TestListEntries(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("A", "u", "p", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("B", "u", "p", nil)), 201)
list := c.mustList(c.req("GET", "/api/entries", nil), 200)
if len(list) != 2 {
t.Fatalf("expected 2 entries, got %d", len(list))
}
}
func TestDeleteEntry(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
created := c.must(c.req("POST", "/api/entries", credentialEntry("Del", "u", "p", nil)), 201)
id := created["entry_id"].(string)
c.must(c.req("DELETE", "/api/entries/"+id, nil), 200)
// Should be gone
list := c.mustList(c.req("GET", "/api/entries", nil), 200)
if len(list) != 0 {
t.Fatalf("expected 0, got %d", len(list))
}
}
func TestUpsert_creates_when_new(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("PUT", "/api/entries", credentialEntry("Fresh", "u", "p", nil)), 201)
}
func TestUpsert_updates_when_exists(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("PUT", "/api/entries", credentialEntry("Same", "u1", "p1", nil)), 201)
c.must(c.req("PUT", "/api/entries", credentialEntry("Same", "u2", "p2", nil)), 200) // update, not create
list := c.mustList(c.req("GET", "/api/entries", nil), 200)
if len(list) != 1 {
t.Fatalf("expected 1, got %d", len(list))
}
}
func TestSearch(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("GitHub Token", "u", "p", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("AWS Key", "u", "p", nil)), 201)
results := c.mustList(c.req("GET", "/api/search?q=GitHub", nil), 200)
if len(results) != 1 {
t.Fatalf("expected 1 search result, got %d", len(results))
}
}
func TestPasswordGenerator(t *testing.T) {
c := newTestClient(t)
result := c.must(c.req("GET", "/api/generate", nil), 200)
pw := result["password"].(string)
if len(pw) < 10 {
t.Fatalf("password too short: %s", pw)
}
}
func TestAuditLog(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("Audit Test", "u", "p", nil)), 201)
events := c.mustList(c.req("GET", "/api/audit", nil), 200)
if len(events) < 1 {
t.Fatal("expected at least 1 audit event")
}
}
// ---------------------------------------------------------------------------
// Scoped Access
// ---------------------------------------------------------------------------
func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create agent
credential := c.createAgent("Claude Code", false)
// Get agent info to find its scope
agents := c.mustList(c.req("GET", "/api/agents", nil), 200)
if len(agents) != 1 {
t.Fatalf("expected 1 agent, got %d", len(agents))
}
agentScope := agents[0]["scopes"].(string)
// Create two entries
c.must(c.req("POST", "/api/entries", credentialEntry("Scoped", "u", "p", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("Owner Only", "u", "p", nil)), 201)
// Assign agent scope to one entry
ownerEntries := c.mustList(c.req("GET", "/api/entries", nil), 200)
for _, e := range ownerEntries {
if e["title"] == "Scoped" {
c.must(c.req("PUT", "/api/entries/"+e["entry_id"].(string)+"/scopes", map[string]any{
"scopes": agentScope,
}), 200)
}
}
// Agent sees only scoped entry
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
if len(agentEntries) != 1 {
t.Fatalf("agent should see 1 entry, got %d", len(agentEntries))
}
if agentEntries[0]["title"] != "Scoped" {
t.Errorf("wrong entry: %v", agentEntries[0]["title"])
}
}
func TestScopedAccess_agent_forbidden_on_unscoped(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
credential := c.createAgent("Test Agent", false)
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Secret", "u", "p", nil)), 201)
entryID := entry["entry_id"].(string)
resp := c.reqAgent(credential, "GET", "/api/entries/"+entryID, nil)
if resp.StatusCode != 403 {
resp.Body.Close()
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_all_access_sees_everything(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
credential := c.createAgent("Full Access", true)
c.must(c.req("POST", "/api/entries", credentialEntry("Owner Secret", "u", "p", nil)), 201)
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
if len(agentEntries) != 1 {
t.Fatalf("all_access agent should see 1 entry, got %d", len(agentEntries))
}
}
func TestScopedAccess_agent_cannot_manage_agents(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
credential := c.createAgent("Evil Agent", false)
// Agent tries to create another agent
resp := c.reqAgent(credential, "POST", "/api/agents", map[string]any{"name": "Backdoor"})
if resp.StatusCode != 403 {
resp.Body.Close()
t.Fatalf("agent should not create agents: got %d", resp.StatusCode)
}
resp.Body.Close()
// Agent tries to list agents
resp = c.reqAgent(credential, "GET", "/api/agents", nil)
if resp.StatusCode != 403 {
resp.Body.Close()
t.Fatalf("agent should not list agents: got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_agent_cannot_modify_scopes(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
credential := c.createAgent("Scope Hijacker", false)
entry := c.must(c.req("POST", "/api/entries", credentialEntry("Target", "u", "p", nil)), 201)
entryID := entry["entry_id"].(string)
resp := c.reqAgent(credential, "PUT", "/api/entries/"+entryID+"/scopes", map[string]any{"scopes": "0001"})
if resp.StatusCode != 403 {
resp.Body.Close()
t.Fatalf("agent should not modify scopes: got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestScopedAccess_agent_entries_invisible(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
credential := c.createAgent("Agent A", true) // all_access
c.must(c.req("POST", "/api/entries", credentialEntry("Normal Entry", "u", "p", nil)), 201)
// Agent should see entries but NOT agent entries (type filtering)
agentEntries := c.mustList(c.reqAgent(credential, "GET", "/api/entries", nil), 200)
for _, e := range agentEntries {
if e["type"] == "agent" || e["type"] == "scope" {
t.Fatalf("agent/scope entries should be hidden from list, got type=%v", e["type"])
}
}
}
// ---------------------------------------------------------------------------
// Key Leak Detection
// ---------------------------------------------------------------------------
func TestKeyLeak_L1_not_in_responses(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("Leak Test", "u", "p", nil)), 201)
l1Hex := hex.EncodeToString(fakeL1())
l1B64 := base64.StdEncoding.EncodeToString(fakeL1())
// Check list response
body := c.mustRaw(c.req("GET", "/api/entries", nil), 200)
bodyStr := string(body)
if strings.Contains(bodyStr, l1Hex) {
t.Fatal("L1 key (hex) found in entries response")
}
if strings.Contains(bodyStr, l1B64) {
t.Fatal("L1 key (b64) found in entries response")
}
}
func TestKeyLeak_L2_not_in_responses(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
// Create agent — response includes credential token (which contains L2 encrypted)
// but should NOT contain raw L2
l2Hex := hex.EncodeToString(fakeL2())
l2B64 := base64.StdEncoding.EncodeToString(fakeL2())
body := c.mustRaw(c.req("POST", "/api/agents", map[string]any{
"name": "Leak Check Agent",
}), 201)
bodyStr := string(body)
if strings.Contains(bodyStr, l2Hex) {
t.Fatal("L2 key (hex) found in agent creation response")
}
if strings.Contains(bodyStr, l2B64) {
t.Fatal("L2 key (b64) found in agent creation response")
}
}
func TestKeyLeak_L3_never_appears(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("L3 Test", "u", "p", nil)), 201)
c.createAgent("L3 Agent", false)
l3Hex := hex.EncodeToString(fakeL3())
l3B64 := base64.StdEncoding.EncodeToString(fakeL3())
// Check multiple endpoints
endpoints := []struct{ method, path string }{
{"GET", "/api/entries"},
{"GET", "/api/agents"},
{"GET", "/api/audit"},
{"GET", "/health"},
}
for _, ep := range endpoints {
body := c.mustRaw(c.req(ep.method, ep.path, nil), 200)
bodyStr := string(body)
if strings.Contains(bodyStr, l3Hex) {
t.Fatalf("L3 key (hex) found in %s %s", ep.method, ep.path)
}
if strings.Contains(bodyStr, l3B64) {
t.Fatalf("L3 key (b64) found in %s %s", ep.method, ep.path)
}
}
}
func TestKeyLeak_agent_credential_is_opaque(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
result := c.must(c.req("POST", "/api/agents", map[string]any{"name": "Opaque Test"}), 201)
credential := result["credential"].(string)
// Credential must start with cvt_
if !strings.HasPrefix(credential, "cvt_") {
t.Fatalf("credential should start with cvt_, got: %s", credential[:10])
}
// Raw L1 and L2 bytes should NOT appear as substrings
l1Hex := hex.EncodeToString(fakeL1())
l2Hex := hex.EncodeToString(fakeL2())
if strings.Contains(credential, l1Hex) {
t.Fatal("L1 hex found in credential string")
}
if strings.Contains(credential, l2Hex) {
t.Fatal("L2 hex found in credential string")
}
}
// ---------------------------------------------------------------------------
// TOTP
// ---------------------------------------------------------------------------
func TestTOTP_valid_code(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
entry := c.must(c.req("POST", "/api/entries", map[string]any{
"title": "TOTP Test", "type": "credential",
"data": map[string]any{
"title": "TOTP Test", "type": "credential",
"fields": []map[string]any{
{"label": "TOTP", "value": "JBSWY3DPEHPK3PXP", "kind": "totp"},
},
},
}), 201)
id := entry["entry_id"].(string)
result := c.must(c.req("GET", "/api/ext/totp/"+id, nil), 200)
code := result["code"].(string)
if len(code) != 6 {
t.Fatalf("expected 6-digit code, got %s", code)
}
}
func TestURLMatch(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("GitHub", "octocat", "p", []string{"https://github.com"})), 201)
results := c.mustList(c.req("GET", "/api/ext/match?url=https://github.com/login", nil), 200)
if len(results) != 1 {
t.Fatalf("expected 1 match, got %d", len(results))
}
}
// ---------------------------------------------------------------------------
// Auth endpoints
// ---------------------------------------------------------------------------
func TestAuthStatus_fresh(t *testing.T) {
c := newTestClient(t)
result := c.must(c.reqNoAuth("GET", "/api/auth/status", nil), 200)
if result["state"] != "fresh" {
t.Fatalf("expected fresh, got %v", result["state"])
}
}
func TestAuthRegisterBegin(t *testing.T) {
c := newTestClient(t)
result := c.must(c.reqNoAuth("POST", "/api/auth/register/begin", nil), 200)
pk := result["publicKey"].(map[string]any)
if pk["challenge"] == nil {
t.Fatal("no challenge in response")
}
}
// ---------------------------------------------------------------------------
// Metadata list
// ---------------------------------------------------------------------------
func TestListEntries_meta(t *testing.T) {
c := newTestClient(t)
c.must(c.req("POST", "/api/auth/setup", nil), 200)
c.must(c.req("POST", "/api/entries", credentialEntry("Meta A", "u", "p", nil)), 201)
c.must(c.req("POST", "/api/entries", credentialEntry("Meta B", "u", "p", nil)), 201)
list := c.mustList(c.req("GET", "/api/entries?meta=1", nil), 200)
if len(list) != 2 {
t.Fatalf("expected 2, got %d", len(list))
}
// Meta mode should not include VaultData
if list[0]["data"] != nil {
t.Fatal("meta mode should not include data")
}
}