mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
Seal the four credential columns with AES-256-GCM (random nonce per value,
AAD "<table>.<column>"): llm_profiles.api_key and task_credentials.password
store "enc:v1:<base64(nonce||ciphertext)>"; mcp_servers.env and
notification_channels.config store {"__artex_enc":"enc:v1:..."} over the
canonical JSON. The db layer encrypts on write and decrypts on read, so agent
tools (LLM provider, MCP, notifier, test credentials) get plaintext
server-side. Decryption failure is an error, never a plaintext fallback.
Key: ARTEX_SECRET_KEY (32 bytes, base64 or hex) or keys/secret.key next to
jwt.key, generated with crypto/rand at first start (0600). Startup refuses
when encrypted values exist and no key is available, and when the key does
not decrypt them. An idempotent backfill under the schema advisory lock
encrypts legacy plaintext rows and logs only a count.
API: MCP env is masked on read and masked placeholders on write keep the
stored value; task credential passwords are masked server-side and a masked
password on write is refused. ARTEX_SECRET_KEY is scrubbed from the process
environment with the other startup secrets.
113 lines
4.2 KiB
Go
113 lines
4.2 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
)
|
|
|
|
// Task credentials (#3): declared at creation, listed/upserted/deleted via the
|
|
// API, and recorded by the agent path (ExplorationStore upsert, source=agent).
|
|
func TestTaskCredentialsFlow(t *testing.T) {
|
|
s, _, request := trafficEvidenceServer(t)
|
|
|
|
// Create a task carrying two credentials: one complete, one "agent generates".
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
resp := request("POST", "/api/tasks", `{
|
|
"description": "credential flow test",
|
|
"goal": "Verify credential handling against the lab target at http://127.0.0.1:9/v1 and record the outcome.",
|
|
"credentials": [
|
|
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
|
|
{"domain": "target.example.com", "username": "", "password": ""}
|
|
]
|
|
}`)
|
|
if resp.Code != http.StatusBadRequest {
|
|
t.Fatalf("an entry without a username must be rejected, got HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
|
|
resp = request("POST", "/api/tasks", `{
|
|
"description": "credential flow test",
|
|
"goal": "Verify credential handling against the lab target at http://127.0.0.1:9/v1 and record the outcome.",
|
|
"credentials": [
|
|
{"domain": "target.example.com", "username": "alias@test", "password": "s3cret!", "note": "program alias account"},
|
|
{"domain": "app.target.example.com", "username": "agent-account", "password": ""}
|
|
]
|
|
}`)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("create with credentials failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &created); err != nil || created.ID == "" {
|
|
t.Fatalf("bad create response: %s", resp.Body.String())
|
|
}
|
|
taskNum := json.Number(created.ID)
|
|
id, err := taskNum.Int64()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { s.m.PG().Exec(`DELETE FROM tasks WHERE id=$1`, id) })
|
|
|
|
// List: both rows, user source, order stable.
|
|
resp = request("GET", fmt.Sprintf("/api/tasks/%s/credentials", created.ID), "")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("list failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var list []db.TaskCredential
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("want 2 credentials, got %d: %s", len(list), resp.Body.String())
|
|
}
|
|
if list[0].Domain != "target.example.com" || list[0].Username != "alias@test" || list[0].Password != "__masked__" || list[0].Note != "program alias account" {
|
|
t.Fatalf("declared credential mismatch: %+v", list[0])
|
|
}
|
|
if list[1].Password != "" {
|
|
t.Fatalf("agent-generates entry must keep an empty password until recorded, got %q", list[1].Password)
|
|
}
|
|
|
|
// The agent path records the generated password onto the empty entry.
|
|
if _, err := s.m.PG().UpsertTaskCredential(id, db.TaskCredentialInput{
|
|
Domain: "app.target.example.com", Username: "agent-account", Password: "generated-strong-pass",
|
|
Source: "agent",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err = s.m.PG().TaskCredentials(id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 2 || list[1].Password != "generated-strong-pass" || list[1].Source != "agent" {
|
|
t.Fatalf("agent record must update in place, got %+v", list[1])
|
|
}
|
|
|
|
// Manual add + delete through the API.
|
|
resp = request("POST", fmt.Sprintf("/api/tasks/%s/credentials", created.ID),
|
|
`{"domain":"other.example.com","username":"relay","password":"otp-verified"}`)
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("manual add failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
var added db.TaskCredential
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &added); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/%d", created.ID, added.ID), "")
|
|
if resp.Code != http.StatusOK {
|
|
t.Fatalf("delete failed, HTTP %d: %s", resp.Code, resp.Body.String())
|
|
}
|
|
list, _ = s.m.PG().TaskCredentials(id)
|
|
if len(list) != 2 {
|
|
t.Fatalf("after delete want 2 credentials, got %d", len(list))
|
|
}
|
|
|
|
// A delete from another task must not cross tasks.
|
|
resp = request("DELETE", fmt.Sprintf("/api/tasks/%s/credentials/999999", created.ID), "")
|
|
if resp.Code != http.StatusNotFound {
|
|
t.Fatalf("foreign credential delete must 404, got HTTP %d", resp.Code)
|
|
}
|
|
}
|