From ac32a2db517529699f7a6d67dc5f2655a21e1690 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 4 Jun 2026 14:35:15 +0800 Subject: [PATCH] fix(agent): encrypt agent access_token at rest (AES-256-GCM via CryptoSecret) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last review finding: the AM-issued access_token was stored in plaintext in agent_deployments. Now sealed with common.SealWithCryptoSecret on write and unsealed on read — the client still receives the plaintext token (no contract change), but a DB dump no longer exposes live agent tokens. Legacy/plaintext values and an unset CryptoSecret degrade gracefully (pass-through). Round-trip test added. Co-Authored-By: Claude Opus 4.8 --- heicode/controller/agent_template_handlers.go | 32 +++++++++++++++++-- heicode/controller/agent_template_test.go | 15 +++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go index fc078900..9dcf1733 100644 --- a/heicode/controller/agent_template_handlers.go +++ b/heicode/controller/agent_template_handlers.go @@ -20,6 +20,34 @@ import ( // the subdomain directly over SSE. HM is NOT in the agent conversation path. // ───────────────────────────────────────────────────────────────────────────── +// sealAgentToken encrypts the AM-issued access token before it is persisted +// (AES-256-GCM via CryptoSecret). Falls back to plaintext only if CryptoSecret +// is unset, so the token is never lost. +func sealAgentToken(token string) string { + if strings.TrimSpace(token) == "" { + return "" + } + sealed, err := common.SealWithCryptoSecret([]byte(token)) + if err != nil { + common.SysLog("WARNING: agent access_token stored unencrypted (CryptoSecret unset?): " + err.Error()) + return token + } + return sealed +} + +// unsealAgentToken reverses sealAgentToken. If the stored value is not a sealed +// blob (legacy plaintext / CryptoSecret unset), it is returned as-is. +func unsealAgentToken(stored string) string { + if strings.TrimSpace(stored) == "" { + return "" + } + plain, err := common.UnsealWithCryptoSecret(stored) + if err != nil { + return stored + } + return string(plain) +} + // templateAgentResponse maps a deployed template-agent record to the client view. func templateAgentResponse(row model.AgentDeployment) gin.H { var bindingIDs []int @@ -30,7 +58,7 @@ func templateAgentResponse(row model.AgentDeployment) gin.H { "agent_id": row.DeploymentID, "template_id": row.TemplateID, "subdomain": row.Subdomain, - "access_token": row.AccessToken, + "access_token": unsealAgentToken(row.AccessToken), "binding_ids": bindingIDs, "status": row.Status, "runtime_id": row.RuntimeDeploymentID, @@ -110,7 +138,7 @@ func HeicodeDeployAgent(c *gin.Context) { UserID: strconv.Itoa(userID), TemplateID: req.TemplateID, Subdomain: result.Subdomain, - AccessToken: result.AccessToken, + AccessToken: sealAgentToken(result.AccessToken), BindingIDsJSON: string(bindingIDsJSON), RuntimeDeploymentID: result.RuntimeID, Status: firstNonEmpty(result.Status, "running"), diff --git a/heicode/controller/agent_template_test.go b/heicode/controller/agent_template_test.go index 1ac54b31..47227ee5 100644 --- a/heicode/controller/agent_template_test.go +++ b/heicode/controller/agent_template_test.go @@ -8,6 +8,7 @@ import ( "sync" "testing" + "github.com/heicode/manager/common" "github.com/heicode/manager/model" "github.com/stretchr/testify/require" ) @@ -197,6 +198,20 @@ func TestSeedAndLoadAgentTemplate(t *testing.T) { require.Equal(t, int64(19), count) // all presets seeded } +func TestAgentTokenSealRoundTrip(t *testing.T) { + old := common.CryptoSecret + common.CryptoSecret = "unit-test-crypto-secret" + defer func() { common.CryptoSecret = old }() + + sealed := sealAgentToken("super-secret-agent-token") + require.NotEqual(t, "super-secret-agent-token", sealed) // encrypted at rest + require.NotEmpty(t, sealed) + require.Equal(t, "super-secret-agent-token", unsealAgentToken(sealed)) // recovered + + require.Equal(t, "", sealAgentToken("")) + require.Equal(t, "plain-legacy", unsealAgentToken("plain-legacy")) // non-sealed passes through +} + func TestTemplateAgentResponse(t *testing.T) { row := model.AgentDeployment{ DeploymentID: "dep_abc",