test: rewrite agent credential tests for client-side generation

Rewrites 7 skipped integration tests to work with client-side credential generation:

- TestScopedAccess_agent_sees_only_scoped_entries
- TestScopedAccess_agent_forbidden_on_unscoped
- TestScopedAccess_all_access_sees_everything
- TestScopedAccess_agent_cannot_manage_agents
- TestScopedAccess_agent_cannot_create_system_types (renamed from _modify_scopes)
- TestScopedAccess_agent_entries_invisible
- TestKeyLeak_agent_credential_is_opaque

Adds MintCredential/ParseCredential test helpers to lib/cvt.go for creating
type 0x01 client credential tokens in tests. These simulate the client-side
credential generation that normally happens in browser/CLI.

Adds test helper methods to integration_test.go:
- reqAgent(): sends requests with CVT wire token authentication
- mintWireToken(): creates type 0x00 wire tokens for agent auth

Security boundaries tested:
- Agents with limited scope cannot access owner-only entries
- Agents with all_access can see all entries
- Agents cannot manage other agents (create/list)
- Agents cannot create system-type entries (agent, scope)
- Server responses never contain raw L2/L3 key material

Fixes #14
This commit is contained in:
James 2026-04-09 03:45:42 -04:00
parent af47846f23
commit def0c6fb1d
3 changed files with 326 additions and 13 deletions

View File

@ -122,6 +122,37 @@ func (c *tc) reqNoAuth(method, path string, body any) *http.Response {
return resp 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. // must asserts status code and returns parsed JSON object.
func (c *tc) must(resp *http.Response, wantStatus int) map[string]any { func (c *tc) must(resp *http.Response, wantStatus int) map[string]any {
c.t.Helper() c.t.Helper()
@ -323,29 +354,195 @@ func TestAuditLog(t *testing.T) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) { func TestScopedAccess_agent_sees_only_scoped_entries(t *testing.T) {
// AGENT CREDENTIALS NOW GENERATED CLIENT-SIDE c := newTestClient(t)
// Tests requiring agent tokens need client-side credential generation c.must(c.req("POST", "/api/auth/setup", nil), 200)
t.Skip("Agent credentials now client-side - test needs rewrite")
// 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) { 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) { 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) { 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) { func TestScopedAccess_agent_cannot_create_system_types(t *testing.T) {
t.Skip("Agent credentials now client-side - test needs rewrite") // 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) { 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) { func TestKeyLeak_agent_credential_is_opaque(t *testing.T) {
// CREDENTIAL GENERATION IS NOW CLIENT-SIDE // CREDENTIAL GENERATION IS NOW CLIENT-SIDE
// Server returns agent_id, web UI generates credential using crypto.js // Server returns agent_id only, web UI generates credential using crypto.js
// This test is obsolete - credential opacity is ensured by client-side implementation // This test verifies that the server response contains no key material
t.Skip("Credential generation moved to client-side - server no longer returns credential") 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")
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -21,7 +21,8 @@ import (
// (clavis-cli/src/cvt.c) and never decrypted by Go code — L2 is a hard veto // (clavis-cli/src/cvt.c) and never decrypted by Go code — L2 is a hard veto
// for the server. // for the server.
const ( 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_" 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 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) // CVT envelope: type(1) + L0(4) + AES-GCM(derived(L0), payload)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -59,3 +59,65 @@ func TestCVT_unique(t *testing.T) {
t.Fatal("two tokens with same input should differ (random nonce)") 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")
}
}