From f80d4f757bc01f4b4f5b1ae505b16b8a35cbb5f9 Mon Sep 17 00:00:00 2001 From: mrh997 Date: Mon, 4 Aug 2025 20:03:31 +0800 Subject: [PATCH] fix(plugin): enhanced AES encryption security (#533) --- .../crossdomain/plugin/plugin_manifest.go | 15 +- backend/application/plugin/plugin.go | 8 +- .../plugin/internal/dal/plugin_oauth_auth.go | 18 ++- backend/domain/plugin/service/plugin_oauth.go | 8 +- backend/domain/plugin/utils/aes.go | 139 ++++++++++++++++-- backend/domain/plugin/utils/aes_test.go | 50 +++++++ docker/.env.debug.example | 7 + docker/.env.example | 7 + 8 files changed, 230 insertions(+), 22 deletions(-) create mode 100644 backend/domain/plugin/utils/aes_test.go diff --git a/backend/api/model/crossdomain/plugin/plugin_manifest.go b/backend/api/model/crossdomain/plugin/plugin_manifest.go index 766b1e26..057fa53e 100644 --- a/backend/api/model/crossdomain/plugin/plugin_manifest.go +++ b/backend/api/model/crossdomain/plugin/plugin_manifest.go @@ -19,6 +19,7 @@ package plugin import ( "encoding/json" "net/url" + "os" "strings" api "github.com/coze-dev/coze-studio/backend/api/model/plugin_develop_common" @@ -74,7 +75,12 @@ func (mf *PluginManifest) EncryptAuthPayload() (*PluginManifest, error) { return mf_, nil } - payload_, err := utils.EncryptByAES([]byte(mf_.Auth.Payload), utils.AuthSecretKey) + secret := os.Getenv(utils.AuthSecretEnv) + if secret == "" { + secret = utils.DefaultAuthSecret + } + + payload_, err := utils.EncryptByAES([]byte(mf_.Auth.Payload), secret) if err != nil { return nil, err } @@ -357,7 +363,12 @@ func (au *AuthV2) UnmarshalJSON(data []byte) error { } if auth.Payload != "" { - payload_, err := utils.DecryptByAES(auth.Payload, utils.AuthSecretKey) + secret := os.Getenv(utils.AuthSecretEnv) + if secret == "" { + secret = utils.DefaultAuthSecret + } + + payload_, err := utils.DecryptByAES(auth.Payload, secret) if err == nil { auth.Payload = string(payload_) } diff --git a/backend/application/plugin/plugin.go b/backend/application/plugin/plugin.go index 99adb619..3ea77217 100644 --- a/backend/application/plugin/plugin.go +++ b/backend/application/plugin/plugin.go @@ -23,6 +23,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strconv" "strings" "time" @@ -1703,7 +1704,12 @@ func (p *PluginApplicationService) OauthAuthorizationCode(ctx context.Context, r return nil, errorx.WrapByCode(err, errno.ErrPluginOAuthFailed, errorx.KV(errno.PluginMsgKey, "invalid state")) } - stateBytes, err := utils.DecryptByAES(stateStr, utils.StateSecretKey) + secret := os.Getenv(utils.StateSecretEnv) + if secret == "" { + secret = utils.DefaultStateSecret + } + + stateBytes, err := utils.DecryptByAES(stateStr, secret) if err != nil { return nil, errorx.WrapByCode(err, errno.ErrPluginOAuthFailed, errorx.KV(errno.PluginMsgKey, "invalid state")) } diff --git a/backend/domain/plugin/internal/dal/plugin_oauth_auth.go b/backend/domain/plugin/internal/dal/plugin_oauth_auth.go index eea9e795..4b3ca003 100644 --- a/backend/domain/plugin/internal/dal/plugin_oauth_auth.go +++ b/backend/domain/plugin/internal/dal/plugin_oauth_auth.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "gorm.io/gorm" @@ -42,14 +43,19 @@ func NewPluginOAuthAuthDAO(db *gorm.DB, idGen idgen.IDGenerator) *PluginOAuthAut type pluginOAuthAuthPO model.PluginOauthAuth func (p pluginOAuthAuthPO) ToDO() *entity.AuthorizationCodeInfo { + secret := os.Getenv(utils.OAuthTokenSecretEnv) + if secret == "" { + secret = utils.DefaultOAuthTokenSecret + } + if p.RefreshToken != "" { - refreshToken, err := utils.DecryptByAES(p.RefreshToken, utils.OAuthTokenSecretKey) + refreshToken, err := utils.DecryptByAES(p.RefreshToken, secret) if err == nil { p.RefreshToken = string(refreshToken) } } if p.AccessToken != "" { - accessToken, err := utils.DecryptByAES(p.AccessToken, utils.OAuthTokenSecretKey) + accessToken, err := utils.DecryptByAES(p.AccessToken, secret) if err == nil { p.AccessToken = string(accessToken) } @@ -103,16 +109,20 @@ func (p *PluginOAuthAuthDAO) Upsert(ctx context.Context, info *entity.Authorizat } meta := info.Meta + secret := os.Getenv(utils.OAuthTokenSecretEnv) + if secret == "" { + secret = utils.DefaultOAuthTokenSecret + } var accessToken, refreshToken string if info.AccessToken != "" { - accessToken, err = utils.EncryptByAES([]byte(info.AccessToken), utils.OAuthTokenSecretKey) + accessToken, err = utils.EncryptByAES([]byte(info.AccessToken), secret) if err != nil { return err } } if info.RefreshToken != "" { - refreshToken, err = utils.EncryptByAES([]byte(info.RefreshToken), utils.OAuthTokenSecretKey) + refreshToken, err = utils.EncryptByAES([]byte(info.RefreshToken), secret) if err != nil { return err } diff --git a/backend/domain/plugin/service/plugin_oauth.go b/backend/domain/plugin/service/plugin_oauth.go index a29f3c02..0be856bc 100644 --- a/backend/domain/plugin/service/plugin_oauth.go +++ b/backend/domain/plugin/service/plugin_oauth.go @@ -437,7 +437,13 @@ func genAuthURL(info *entity.AuthorizationCodeInfo) (string, error) { if err != nil { return "", fmt.Errorf("marshal state failed, err=%v", err) } - encryptState, err := utils.EncryptByAES(stateStr, utils.StateSecretKey) + + secret := os.Getenv(utils.StateSecretEnv) + if secret == "" { + secret = utils.DefaultStateSecret + } + + encryptState, err := utils.EncryptByAES(stateStr, secret) if err != nil { return "", fmt.Errorf("encrypt state failed, err=%v", err) } diff --git a/backend/domain/plugin/utils/aes.go b/backend/domain/plugin/utils/aes.go index 402f495a..1e7cd898 100644 --- a/backend/domain/plugin/utils/aes.go +++ b/backend/domain/plugin/utils/aes.go @@ -20,18 +20,131 @@ import ( "bytes" "crypto/aes" "crypto/cipher" + "crypto/rand" "encoding/base64" + "encoding/json" "fmt" + "io" + + "github.com/bytedance/gopkg/util/logger" ) const ( - AuthSecretKey = "^*6x3hdu2nc%-p38" - StateSecretKey = "osj^kfhsd*(z!sno" - OAuthTokenSecretKey = "cn+$PJ(HhJ[5d*z9" + AuthSecretEnv = "PLUGIN_AES_AUTH_SECRET" + StateSecretEnv = "PLUGIN_AES_STATE_SECRET" + OAuthTokenSecretEnv = "PLUGIN_AES_OAUTH_TOKEN_SECRET" ) -func EncryptByAES(val []byte, secretKey string) (string, error) { - sb := []byte(secretKey) +const encryptVersion = "aes-cbc-v1" + +// In order to be compatible with the problem of no existing env configuration, +// these default values are temporarily retained. +const ( + // Deprecated. Configuring AuthSecretEnv in env instead. + DefaultAuthSecret = "^*6x3hdu2nc%-p38" + // Deprecated. Configuring StateSecretEnv in env instead. + DefaultStateSecret = "osj^kfhsd*(z!sno" + // Deprecated. Configuring OAuthTokenSecretEnv in env instead. + DefaultOAuthTokenSecret = "cn+$PJ(HhJ[5d*z9" +) + +type AESEncryption struct { + Version string `json:"version"` + IV []byte `json:"iv"` + EncryptedData []byte `json:"encrypted_data"` +} + +func EncryptByAES(val []byte, secret string) (string, error) { + if secret == "" { + return "", fmt.Errorf("secret is required") + } + + sb := []byte(secret) + + block, err := aes.NewCipher(sb) + if err != nil { + return "", err + } + + blockSize := block.BlockSize() + paddingData := pkcs7Padding(val, blockSize) + + iv := make([]byte, blockSize) + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + encrypted := make([]byte, len(paddingData)) + blockMode := cipher.NewCBCEncrypter(block, iv) + blockMode.CryptBlocks(encrypted, paddingData) + + en := &AESEncryption{ + Version: encryptVersion, + IV: iv, + EncryptedData: encrypted, + } + + encrypted, err = json.Marshal(en) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(encrypted), nil +} + +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + + return append(data, padText...) +} + +func DecryptByAES(data, secret string) ([]byte, error) { + if secret == "" { + return nil, fmt.Errorf("secret is required") + } + + enBytes, err := base64.RawURLEncoding.DecodeString(data) + if err != nil { + return nil, err + } + + en := &AESEncryption{} + err = json.Unmarshal(enBytes, &en) + if err != nil { // fallback to unsafeEncryptByAES + logger.Warnf("failed to unmarshal encrypted data, fallback to unsafeEncryptByAES: %v", err) + return UnsafeDecryptByAES(data, secret) + } + + sb := []byte(secret) + + block, err := aes.NewCipher(sb) + if err != nil { + return nil, err + } + + blockMode := cipher.NewCBCDecrypter(block, en.IV) + + if len(en.EncryptedData)%blockMode.BlockSize() != 0 { + return nil, fmt.Errorf("invalid block size") + } + + decrypted := make([]byte, len(en.EncryptedData)) + blockMode.CryptBlocks(decrypted, en.EncryptedData) + + decrypted, err = pkcs7UnPadding(decrypted) + if err != nil { + return nil, err + } + + return decrypted, nil +} + +// Deprecated: use EncryptByAES instead +// UnsafeEncryptByAES is an insecure encryption method, +// because the iv is fixed using the first 16 bits of the secret. +func UnsafeEncryptByAES(val []byte, secret string) (string, error) { + sb := []byte(secret) block, err := aes.NewCipher(sb) if err != nil { @@ -48,20 +161,18 @@ func EncryptByAES(val []byte, secretKey string) (string, error) { return base64.RawURLEncoding.EncodeToString(encrypted), nil } -func pkcs7Padding(data []byte, blockSize int) []byte { - padding := blockSize - len(data)%blockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - - return append(data, padText...) -} - -func DecryptByAES(data, secretKey string) ([]byte, error) { +// Deprecated: use DecryptByAES instead +// UnsafeDecryptByAES is an insecure decryption method, +// because the iv is fixed using the first 16 bits of the secret. +// In order to be compatible with existing data that has been encrypted by UnsafeEncryptByAES, +// this method is retained as a fallback decryption method. +func UnsafeDecryptByAES(data, secret string) ([]byte, error) { dataBytes, err := base64.RawURLEncoding.DecodeString(data) if err != nil { return nil, err } - sb := []byte(secretKey) + sb := []byte(secret) block, err := aes.NewCipher(sb) if err != nil { diff --git a/backend/domain/plugin/utils/aes_test.go b/backend/domain/plugin/utils/aes_test.go new file mode 100644 index 00000000..bc2c2395 --- /dev/null +++ b/backend/domain/plugin/utils/aes_test.go @@ -0,0 +1,50 @@ +/* + * Copyright 2025 coze-dev Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package utils + +import ( + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" +) + +func TestDecryptByAES(t *testing.T) { + mockey.PatchConvey("unsafe encryption compatibility", t, func() { + secret := "test_secret_1234" + plaintext := []byte("test_plaintext") + + encrypted, err := UnsafeEncryptByAES(plaintext, secret) + assert.NoError(t, err) + + decrypted, err := DecryptByAES(encrypted, secret) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + }) + + mockey.PatchConvey("safe encryption", t, func() { + secret := "test_secret_1234" + plaintext := []byte("test_plaintext") + + encrypted, err := EncryptByAES(plaintext, secret) + assert.NoError(t, err) + + decrypted, err := DecryptByAES(encrypted, secret) + assert.NoError(t, err) + assert.Equal(t, plaintext, decrypted) + }) +} diff --git a/docker/.env.debug.example b/docker/.env.debug.example index fef45e7b..8e89a125 100644 --- a/docker/.env.debug.example +++ b/docker/.env.debug.example @@ -219,3 +219,10 @@ export CODE_RUNNER_MEMORY_LIMIT_MB="" export DISABLE_USER_REGISTRATION="" # default "", if you want to disable, set to true export ALLOW_REGISTRATION_EMAIL="" # is a list of email addresses, separated by ",". Example: "11@example.com,22@example.com" +# Plugin AES secret +# PLUGIN_AES_AUTH_SECRET is the secret of used to encrypt plugin authorization payload. +export PLUGIN_AES_AUTH_SECRET="^*6x3hdu2nc%-p38" +# PLUGIN_AES_STATE_SECRET is the secret of used to encrypt oauth state. +export PLUGIN_AES_STATE_SECRET="osj^kfhsd*(z!sno" +# PLUGIN_AES_OAUTH_TOKEN_SECRET is the secret of used to encrypt oauth refresh token and access token. +export PLUGIN_AES_OAUTH_TOKEN_SECRET="cn+$PJ(HhJ[5d*z9" diff --git a/docker/.env.example b/docker/.env.example index 7ddd8a0e..01e76732 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -219,3 +219,10 @@ export CODE_RUNNER_MEMORY_LIMIT_MB="" export DISABLE_USER_REGISTRATION="" # default "", if you want to disable, set to true export ALLOW_REGISTRATION_EMAIL="" # is a list of email addresses, separated by ",". Example: "11@example.com,22@example.com" +# Plugin AES secret +# PLUGIN_AES_AUTH_SECRET is the secret of used to encrypt plugin authorization payload. +export PLUGIN_AES_AUTH_SECRET="^*6x3hdu2nc%-p38" +# PLUGIN_AES_STATE_SECRET is the secret of used to encrypt oauth state. +export PLUGIN_AES_STATE_SECRET="osj^kfhsd*(z!sno" +# PLUGIN_AES_OAUTH_TOKEN_SECRET is the secret of used to encrypt oauth refresh token and access token. +export PLUGIN_AES_OAUTH_TOKEN_SECRET="cn+$PJ(HhJ[5d*z9" \ No newline at end of file