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:
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user