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