Files
ARTEX/server/secrets_api_test.go
T
BaaRai dc169b074f secrets: encrypt search keys, proxies and the LLM profile proxy at rest
Phase 2 of secrets at rest (#213), same AES-256-GCM scheme and key.

- llm_profiles.proxy joins the secret columns (AAD llm_profiles.proxy);
  scanProfile decrypts it for both column lists, SaveProfile encrypts
  it, an empty proxy stays NULL
- brave_search_api_key, tavily_search_api_key, web_search_proxy and
  global_proxy are stored encrypted (AAD settings.<key>) through
  GetSetting, SetSetting and InsertSettingIfAbsent; other settings stay
  in clear
- the startup backfill, the no-key refusal and the wrong-key check
  cover the new values; legacy plaintext is read as-is until backfill

API: the proxies are returned masked as a whole (placeholder plus a
*_set flag) by GET /api/settings, GET /api/llm/profiles and GET /api/llm.
On write the placeholder keeps the stored value, an explicit value
replaces it and "" clears it; the placeholder is never stored. The test
and model-list endpoints resolve the placeholder to the stored proxy.
2026-11-13 21:33:27 +01:00

262 lines
11 KiB
Go

package server
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
)
// No read endpoint returns a stored secret in clear, and a masked placeholder
// sent back on write never replaces the stored secret.
func TestSecretsNeverReturnedInClear(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
secrets := []string{"sk-llm-plain-0001", "mcp-env-plain-0002", "cred-plain-0003", "SECding-plain-0004", "tok-plain-0005"}
task, err := s.m.CreateTask("secrets api", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
llmID, err := pg.SaveProfile(&db.LLMProfile{Name: "t-secapi", Format: "openai", Model: "m", APIKey: secrets[0]})
mcpID, err2 := pg.SaveMCP(&db.MCPServer{Name: "t-secapi", Transport: "http", Env: json.RawMessage(`{"X-API-Key":"` + secrets[1] + `","EMPTY":""}`)})
_, err3 := pg.UpsertTaskCredential(taskID, db.TaskCredentialInput{Domain: "x.example", Username: "u", Password: secrets[2]})
chID, err4 := pg.SaveNotificationChannel(context.Background(), &db.NotificationChannel{Name: "t-secapi", Kind: "dingtalk",
Config: json.RawMessage(`{"webhook":"https://oapi.example/send?access_token=` + secrets[4] + `","secret":"` + secrets[3] + `"}`)})
t.Cleanup(func() {
pg.Exec(`DELETE FROM llm_profiles WHERE id=$1`, llmID)
pg.Exec(`DELETE FROM mcp_servers WHERE id=$1`, mcpID)
pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, chID)
pg.DeleteTask(taskID)
})
for _, e := range []error{err, err2, err3, err4} {
if e != nil {
t.Fatal(e)
}
}
for _, path := range []string{"/api/llm/profiles", "/api/llm", "/api/mcp", "/api/notify/channels", "/api/tasks/" + task.ID + "/credentials"} {
resp := request("GET", path, "")
for _, sec := range secrets {
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
t.Fatalf("GET %s: HTTP %d or a secret returned in clear", path, resp.Code)
}
}
}
// MCP: echo the masked list entry back with a name change; the key survives.
var list struct{ Servers []db.MCPServer }
_ = json.Unmarshal(request("GET", "/api/mcp", "").Body.Bytes(), &list)
for _, m := range list.Servers {
if m.ID != mcpID {
continue
}
if string(m.Env) != `{"EMPTY":"","X-API-Key":"__masked__"}` {
t.Fatalf("mcp env not masked as expected: %s", m.Env)
}
m.Name = "t-secapi-renamed"
body, _ := json.Marshal(m)
if resp := request("POST", "/api/mcp", string(body)); resp.Code != 200 {
t.Fatalf("mcp save: HTTP %d %s", resp.Code, resp.Body.String())
}
}
all, _ := pg.ListMCP()
for _, m := range all {
if m.ID == mcpID && !strings.Contains(string(m.Env), secrets[1]) {
t.Fatal("masked placeholder replaced the stored MCP secret")
}
}
if resp := request("POST", "/api/mcp", `{"name":"t-secapi-new","transport":"http","env":{"K":"__masked__"}}`); resp.Code != 400 {
t.Fatalf("a placeholder with nothing stored must be refused, got HTTP %d", resp.Code)
}
// Task credential: a masked password on write is refused, the stored one kept.
if resp := request("POST", "/api/tasks/"+task.ID+"/credentials", `{"domain":"x.example","username":"u","password":"__masked__"}`); resp.Code != 400 {
t.Fatalf("masked credential write must be refused, got HTTP %d", resp.Code)
}
if creds, _ := pg.TaskCredentials(taskID); len(creds) != 1 || creds[0].Password != secrets[2] {
t.Fatal("stored credential changed")
}
}
// A masked MCP env value is only kept while the destination (transport,
// command, args, url) is unchanged; otherwise the secrets must be re-entered.
func TestMCPMaskedEnvDestinationGuard(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
const secret = "mcp-guard-secret-0006"
httpID, err := pg.SaveMCP(&db.MCPServer{Name: "t-guard-http", Transport: "http", URL: "http://127.0.0.1:9/a",
Env: json.RawMessage(`{"K":"` + secret + `"}`)})
if err != nil {
t.Fatal(err)
}
stdioID, err := pg.SaveMCP(&db.MCPServer{Name: "t-guard-stdio", Transport: "stdio", Command: "npx",
Args: json.RawMessage(`["pkg"]`), Env: json.RawMessage(`{"K":"` + secret + `"}`)})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.DeleteMCP(httpID); pg.DeleteMCP(stdioID) })
stored := func(id int64) db.MCPServer {
all, _ := pg.ListMCP()
for _, m := range all {
if m.ID == id {
return *m
}
}
t.Fatalf("mcp %d missing", id)
return db.MCPServer{}
}
save := func(id int64, edit func(*db.MCPServer), want int) {
t.Helper()
m := stored(id)
m.Env = maskMCPEnv(m.Env)
edit(&m)
body, _ := json.Marshal(m)
if resp := request("POST", "/api/mcp", string(body)); resp.Code != want {
t.Fatalf("want HTTP %d, got %d %s", want, resp.Code, resp.Body.String())
}
}
keeps := func(id int64, url string) {
t.Helper()
if m := stored(id); !strings.Contains(string(m.Env), secret) || m.URL != url {
t.Fatalf("stored row changed: url=%q", m.URL)
}
}
save(httpID, func(m *db.MCPServer) { m.Name = "t-guard-http2" }, 200)
keeps(httpID, "http://127.0.0.1:9/a")
save(httpID, func(m *db.MCPServer) { m.URL = "http://127.0.0.1:9/evil" }, 400)
keeps(httpID, "http://127.0.0.1:9/a")
save(httpID, func(m *db.MCPServer) { m.Transport = "sse" }, 400)
keeps(httpID, "http://127.0.0.1:9/a")
save(stdioID, func(m *db.MCPServer) { m.Command = "sh" }, 400)
save(stdioID, func(m *db.MCPServer) { m.Args = json.RawMessage(`["other"]`) }, 400)
keeps(stdioID, "")
save(stdioID, func(m *db.MCPServer) { m.Args = json.RawMessage(`[ "pkg" ]`) }, 200) // same args, other spacing
keeps(stdioID, "")
save(httpID, func(m *db.MCPServer) {
m.URL, m.Env = "http://127.0.0.1:9/b", json.RawMessage(`{"K":"new-explicit-value"}`)
}, 200)
if m := stored(httpID); m.URL != "http://127.0.0.1:9/b" || !strings.Contains(string(m.Env), "new-explicit-value") {
t.Fatal("explicit env with a new url was not saved")
}
}
// Secret settings and the LLM profile proxy: stored encrypted, never returned
// in clear; on write the placeholder keeps, an explicit value replaces, ""
// clears.
func TestProxySecretsAPI(t *testing.T) {
s, _, request := trafficEvidenceServer(t)
pg := s.m.PG()
t.Cleanup(func() {
pg.Exec(`DELETE FROM settings WHERE key IN ('brave_search_api_key','tavily_search_api_key','web_search_proxy','global_proxy')`)
pg.Exec(`DELETE FROM llm_profiles WHERE name='t-proxy'`)
})
secrets := []string{"brave-api-0007", "tavily-api-0008", "wsp-pass-0009", "gp-pass-0010", "llmp-pass-0011"}
put := func(body string) {
t.Helper()
resp := request("PUT", "/api/settings", body)
for _, sec := range secrets {
if resp.Code != 200 || strings.Contains(resp.Body.String(), sec) {
t.Fatalf("PUT /api/settings %s: HTTP %d or a secret returned in clear", body, resp.Code)
}
}
}
setting := func(k string) string {
t.Helper()
var raw string
if err := pg.QueryRow(`SELECT value FROM settings WHERE key=$1`, k).Scan(&raw); err != nil {
t.Fatal(err)
}
v, _, err := pg.GetSetting(k)
if err != nil || (raw != "" && !strings.HasPrefix(raw, "enc:v1:")) {
t.Fatalf("setting %s stored as %q (%v)", k, raw, err)
}
return v
}
put(`{"brave_search_api_key":"brave-api-0007","tavily_search_api_key":"tavily-api-0008",
"web_search_proxy":"socks5://u:wsp-pass-0009@ws.example:1080","global_proxy":"http://u:gp-pass-0010@gp.example:3128"}`)
var got map[string]any
resp := request("GET", "/api/settings", "")
_ = json.Unmarshal(resp.Body.Bytes(), &got)
for _, sec := range secrets {
if strings.Contains(resp.Body.String(), sec) {
t.Fatal("GET /api/settings returned a secret in clear")
}
}
if got["global_proxy"] != "__masked__" || got["global_proxy_set"] != true || got["web_search_proxy"] != "__masked__" || got["web_search_proxy_set"] != true {
t.Fatalf("proxies not masked with a presence flag: %v", got)
}
put(`{"web_search_proxy":"__masked__","global_proxy":"__masked__","brave_search_api_key":"__masked__"}`)
if setting("web_search_proxy") != "socks5://u:wsp-pass-0009@ws.example:1080" || setting("global_proxy") != "http://u:gp-pass-0010@gp.example:3128" ||
setting("brave_search_api_key") != "brave-api-0007" || setting("tavily_search_api_key") != "tavily-api-0008" {
t.Fatal("a masked placeholder replaced a stored secret")
}
put(`{"global_proxy":"http://gp2.example:3128","web_search_proxy":""}`)
if setting("global_proxy") != "http://gp2.example:3128" || setting("web_search_proxy") != "" {
t.Fatal("explicit value must replace and \"\" must clear")
}
_ = json.Unmarshal(request("GET", "/api/settings", "").Body.Bytes(), &got)
if got["web_search_proxy"] != "" || got["web_search_proxy_set"] != false {
t.Fatalf("cleared proxy shown as %v / %v", got["web_search_proxy"], got["web_search_proxy_set"])
}
// LLM profile proxy.
id, err := pg.SaveProfile(&db.LLMProfile{Name: "t-proxy", Format: "openai", Model: "m", APIKey: "k", Proxy: "http://u:llmp-pass-0011@llm.example:3128"})
if err != nil {
t.Fatal(err)
}
proxy := func() string {
var raw *string
if err := pg.QueryRow(`SELECT proxy FROM llm_profiles WHERE id=$1`, id).Scan(&raw); err != nil {
t.Fatal(err)
}
if raw != nil && !strings.HasPrefix(*raw, "enc:v1:") {
t.Fatalf("profile proxy stored in clear: %q", *raw)
}
p, err := pg.ProfileByID(id)
if err != nil {
t.Fatal(err)
}
return p.Proxy
}
for _, path := range []string{"/api/llm/profiles", "/api/llm"} {
if resp := request("GET", path, ""); resp.Code != 200 || strings.Contains(resp.Body.String(), "llmp-pass-0011") {
t.Fatalf("GET %s: HTTP %d or the proxy returned in clear", path, resp.Code)
}
}
var list struct{ Profiles []LLMProfileDTO }
_ = json.Unmarshal(request("GET", "/api/llm/profiles", "").Body.Bytes(), &list)
for _, p := range list.Profiles {
if p.Name == "t-proxy" && (p.Proxy != "__masked__" || !p.ProxySet) {
t.Fatalf("profile proxy not masked: %+v", p)
}
}
save := func(proxyJSON string, want int) {
t.Helper()
body := `{"id":` + strconv.FormatInt(id, 10) + `,"name":"t-proxy","format":"openai","model":"m","proxy":` + proxyJSON + `}`
if resp := request("POST", "/api/llm/profiles", body); resp.Code != want {
t.Fatalf("save proxy %s: want HTTP %d, got %d %s", proxyJSON, want, resp.Code, resp.Body.String())
}
}
save(`"__masked__"`, 200)
if proxy() != "http://u:llmp-pass-0011@llm.example:3128" {
t.Fatal("the placeholder replaced the stored profile proxy")
}
save(`"socks5://llm2.example:1080"`, 200)
if proxy() != "socks5://llm2.example:1080" {
t.Fatal("explicit proxy not saved")
}
save(`""`, 200)
if proxy() != "" {
t.Fatal("empty proxy must clear")
}
if resp := request("POST", "/api/llm/profiles", `{"name":"t-proxy","format":"openai","model":"m","proxy":"__masked__"}`); resp.Code != 400 {
t.Fatalf("a placeholder on a new profile must be refused, got HTTP %d", resp.Code)
}
}