fix(agent): encrypt agent access_token at rest (AES-256-GCM via CryptoSecret)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 14:35:15 +08:00
co-authored by Claude Opus 4.8
parent 7a48155568
commit ac32a2db51
2 changed files with 45 additions and 2 deletions
+30 -2
View File
@@ -20,6 +20,34 @@ import (
// the subdomain directly over SSE. HM is NOT in the agent conversation path. // 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. // templateAgentResponse maps a deployed template-agent record to the client view.
func templateAgentResponse(row model.AgentDeployment) gin.H { func templateAgentResponse(row model.AgentDeployment) gin.H {
var bindingIDs []int var bindingIDs []int
@@ -30,7 +58,7 @@ func templateAgentResponse(row model.AgentDeployment) gin.H {
"agent_id": row.DeploymentID, "agent_id": row.DeploymentID,
"template_id": row.TemplateID, "template_id": row.TemplateID,
"subdomain": row.Subdomain, "subdomain": row.Subdomain,
"access_token": row.AccessToken, "access_token": unsealAgentToken(row.AccessToken),
"binding_ids": bindingIDs, "binding_ids": bindingIDs,
"status": row.Status, "status": row.Status,
"runtime_id": row.RuntimeDeploymentID, "runtime_id": row.RuntimeDeploymentID,
@@ -110,7 +138,7 @@ func HeicodeDeployAgent(c *gin.Context) {
UserID: strconv.Itoa(userID), UserID: strconv.Itoa(userID),
TemplateID: req.TemplateID, TemplateID: req.TemplateID,
Subdomain: result.Subdomain, Subdomain: result.Subdomain,
AccessToken: result.AccessToken, AccessToken: sealAgentToken(result.AccessToken),
BindingIDsJSON: string(bindingIDsJSON), BindingIDsJSON: string(bindingIDsJSON),
RuntimeDeploymentID: result.RuntimeID, RuntimeDeploymentID: result.RuntimeID,
Status: firstNonEmpty(result.Status, "running"), Status: firstNonEmpty(result.Status, "running"),
+15
View File
@@ -8,6 +8,7 @@ import (
"sync" "sync"
"testing" "testing"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model" "github.com/heicode/manager/model"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -197,6 +198,20 @@ func TestSeedAndLoadAgentTemplate(t *testing.T) {
require.Equal(t, int64(19), count) // all presets seeded 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) { func TestTemplateAgentResponse(t *testing.T) {
row := model.AgentDeployment{ row := model.AgentDeployment{
DeploymentID: "dep_abc", DeploymentID: "dep_abc",