mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 20:20:54 +08:00
Phase 3 of secrets at rest (#213, #216), same AES-256-GCM scheme and key. - tools.exec joins the secret columns for system=false rows (AAD tools.exec): the whole JSON is sealed in the {"__artex_enc":"enc:v1:..."} envelope, so headers, body, proxy, script code and command are covered; {} stays {} - CreateCustomTool and UpdateCustomTool seal it; scanTool opens it, so GetTool, ListTools and ListCustomTools (and through them customTools, the platform tools and the test endpoint) see the clear JSON in memory - the startup backfill, the no-key refusal and the wrong-key check cover it, for system=false rows only; built-in rows stay in clear for the seeds API: GET /api/tools returns the header values, proxy and body of a custom tool masked, with headers_set, proxy_set and body_set. On update (API and update_custom_tool) the placeholder keeps the stored value, an explicit value replaces it and "" clears it; on create (API and create_custom_tool) a placeholder is refused. The test endpoint takes an optional key: the saved tool's stored values replace the placeholders; the editor sends it.
185 lines
6.3 KiB
Go
185 lines
6.3 KiB
Go
package db
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// toolExecPlain is a custom http tool's exec carrying a secret in every field
|
|
// the phase 3 encryption covers.
|
|
const toolExecPlain = `{"method":"GET","url":"https://api.example/x","headers":{"Authorization":"Bearer tool-hdr-plain"},` +
|
|
`"body":"tool-body-plain","proxy":"http://u:tool-proxy-plain@p.example:3128"}`
|
|
|
|
var toolPlainSecrets = []string{"tool-hdr-plain", "tool-body-plain", "tool-proxy-plain", "tool-cmd-plain"}
|
|
|
|
func rawToolExec(t *testing.T, d *DB, key string) string {
|
|
t.Helper()
|
|
var v string
|
|
if err := d.QueryRow(`SELECT exec::text FROM tools WHERE key=$1`, key).Scan(&v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
// assertToolExecSealed checks a custom tool's exec is sealed at rest and read
|
|
// back in clear, equal to want, by every db getter.
|
|
func assertToolExecSealed(t *testing.T, d *DB, key, want string) {
|
|
t.Helper()
|
|
raw := rawToolExec(t, d, key)
|
|
if !isSealed(colToolExec, raw) {
|
|
t.Fatalf("tools.exec of %s not sealed at rest: %s", key, raw)
|
|
}
|
|
for _, p := range toolPlainSecrets {
|
|
if strings.Contains(raw, p) {
|
|
t.Fatalf("plaintext %q found at rest", p)
|
|
}
|
|
}
|
|
var got []json.RawMessage
|
|
if tl, err := d.GetTool(key); err != nil || tl == nil {
|
|
t.Fatalf("GetTool: %v", err)
|
|
} else {
|
|
got = append(got, tl.Exec)
|
|
}
|
|
for _, list := range []func() ([]*Tool, error){d.ListTools, d.ListCustomTools} {
|
|
ts, err := list()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tl := range ts {
|
|
if tl.Key == key {
|
|
got = append(got, tl.Exec)
|
|
}
|
|
}
|
|
}
|
|
if len(got) != 3 {
|
|
t.Fatalf("tool %s read by %d getters, want 3", key, len(got))
|
|
}
|
|
for _, g := range got {
|
|
if !jsonEqual(t, string(g), want) {
|
|
t.Fatalf("exec read back as %s, want %s", g, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func jsonEqual(t *testing.T, a, b string) bool {
|
|
t.Helper()
|
|
var x, y any
|
|
if json.Unmarshal([]byte(a), &x) != nil || json.Unmarshal([]byte(b), &y) != nil {
|
|
return false
|
|
}
|
|
ca, _ := json.Marshal(x)
|
|
cb, _ := json.Marshal(y)
|
|
return string(ca) == string(cb)
|
|
}
|
|
|
|
func TestToolExecSealedAtRest(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
d.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`)
|
|
d.Close()
|
|
})
|
|
if err := d.CreateCustomTool(&Tool{Key: "t_secexec", Kind: "http", Exec: json.RawMessage(toolExecPlain), Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertToolExecSealed(t, d, "t_secexec", toolExecPlain)
|
|
first := rawToolExec(t, d, "t_secexec")
|
|
|
|
cmd := `{"command":"curl -H 'X-Key: tool-cmd-plain' {args}","timeout_ms":5000}`
|
|
if err := d.UpdateCustomTool(&Tool{Key: "t_secexec", Kind: "command", Exec: json.RawMessage(cmd), Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertToolExecSealed(t, d, "t_secexec", cmd)
|
|
if err := d.UpdateCustomTool(&Tool{Key: "t_secexec", Kind: "http", Exec: json.RawMessage(toolExecPlain), Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rawToolExec(t, d, "t_secexec") == first {
|
|
t.Fatal("the same exec must seal under a fresh nonce")
|
|
}
|
|
|
|
// A shell tool has no exec: {} stays {}.
|
|
if err := d.CreateCustomTool(&Tool{Key: "t_secexec_shell", Kind: "shell", Enabled: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if raw := rawToolExec(t, d, "t_secexec_shell"); raw != "{}" {
|
|
t.Fatalf("empty exec stored as %s, want {}", raw)
|
|
}
|
|
if err := d.CreateCustomTool(&Tool{Key: "t_secexec_resv", Kind: "command", Exec: json.RawMessage(`{"__artex_enc":"x"}`)}); err == nil {
|
|
t.Fatal("reserved key must be refused on write")
|
|
}
|
|
|
|
// A value sealed under another key fails the read (fail closed).
|
|
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
|
|
foreign, _ := other.seal(colToolExec, toolExecPlain)
|
|
if _, err := d.Exec(`UPDATE tools SET exec=$1::jsonb WHERE key='t_secexec'`, foreign); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.GetTool("t_secexec"); err == nil || !strings.Contains(err.Error(), "tools.exec") {
|
|
t.Fatalf("a foreign ciphertext must fail the read, got %v", err)
|
|
}
|
|
if _, err := d.ListCustomTools(); err == nil {
|
|
t.Fatal("ListCustomTools must fail on a foreign ciphertext")
|
|
}
|
|
}
|
|
|
|
func TestToolExecBackfillAndKeyChecks(t *testing.T) {
|
|
d, err := Open(testDSN(t))
|
|
if err != nil {
|
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
d.Exec(`DELETE FROM tools WHERE key LIKE 't_secexec%'`)
|
|
d.Close()
|
|
})
|
|
sysExec := `{"command":"echo system-plain"}`
|
|
for _, q := range []struct {
|
|
key, exec string
|
|
system bool
|
|
}{{"t_secexec_legacy", toolExecPlain, false}, {"t_secexec_sys", sysExec, true}, {"t_secexec_empty", `{}`, false}} {
|
|
if _, err := d.Exec(`INSERT INTO tools(key, system, kind, exec) VALUES ($1, $2, 'http', $3::jsonb)`, q.key, q.system, q.exec); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
t.Setenv(secretKeyEnv, base64.StdEncoding.EncodeToString(testSecretKey))
|
|
if err := d.InitSecrets(t.TempDir()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertToolExecSealed(t, d, "t_secexec_legacy", toolExecPlain)
|
|
first := rawToolExec(t, d, "t_secexec_legacy")
|
|
if raw := rawToolExec(t, d, "t_secexec_sys"); !jsonEqual(t, raw, sysExec) {
|
|
t.Fatalf("a system tool's exec must stay in clear, got %s", raw)
|
|
}
|
|
if raw := rawToolExec(t, d, "t_secexec_empty"); raw != "{}" {
|
|
t.Fatalf("an empty exec must stay {}, got %s", raw)
|
|
}
|
|
if err := d.InitSecrets(t.TempDir()); err != nil || rawToolExec(t, d, "t_secexec_legacy") != first {
|
|
t.Fatalf("second backfill must leave the ciphertext untouched (%v)", err)
|
|
}
|
|
|
|
// Missing key: the sealed exec counts in the refusal.
|
|
t.Setenv(secretKeyEnv, "")
|
|
dir := t.TempDir()
|
|
withTool := encryptedCount(t, d, dir)
|
|
if _, err := d.Exec(`UPDATE tools SET exec='{}'::jsonb WHERE key='t_secexec_legacy'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := encryptedCount(t, d, dir); withTool-n != 1 {
|
|
t.Fatalf("no-key refusal counted %d then %d encrypted values; tools.exec must count", withTool, n)
|
|
}
|
|
|
|
// Wrong key: an exec sealed under another key fails the startup key check.
|
|
other, _ := newSecretBox([]byte("ffffffffffffffffffffffffffffffff"))
|
|
foreign, _ := other.seal(colToolExec, toolExecPlain)
|
|
if _, err := d.Exec(`UPDATE tools SET exec=$1::jsonb WHERE key='t_secexec_legacy'`, foreign); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv(secretKeyEnv, base64.StdEncoding.EncodeToString(testSecretKey))
|
|
if err := d.InitSecrets(t.TempDir()); err == nil || !strings.Contains(err.Error(), "tools.exec") {
|
|
t.Fatalf("want a key-check failure on tools.exec, got %v", err)
|
|
}
|
|
}
|