This is an automated email from the ASF dual-hosted git repository.
Startrekzky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 5e9dfb8b8 feat(encryption): implement AES-GCM encryption and
decryption with backward compatibility (#8895)
5e9dfb8b8 is described below
commit 5e9dfb8b8d3915494b468e098efde76696a51fa3
Author: Klesh Wong <[email protected]>
AuthorDate: Sun May 31 19:26:12 2026 +0800
feat(encryption): implement AES-GCM encryption and decryption with backward
compatibility (#8895)
Co-authored-by: Klesh Wong <[email protected]>
---
backend/core/plugin/plugin_utils.go | 66 ++++++++++++++++++++----------
backend/core/plugin/plugin_utils_test.go | 69 ++++++++++++++++++++++++++++++++
2 files changed, 114 insertions(+), 21 deletions(-)
diff --git a/backend/core/plugin/plugin_utils.go
b/backend/core/plugin/plugin_utils.go
index dd0f29ca4..ac8e2090b 100644
--- a/backend/core/plugin/plugin_utils.go
+++ b/backend/core/plugin/plugin_utils.go
@@ -21,9 +21,11 @@ import (
"bytes"
"crypto/aes"
"crypto/cipher"
+ "crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
+ "io"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/core/utils"
@@ -31,8 +33,11 @@ import (
const EncodeKeyEnvStr = "ENCRYPTION_SECRET"
-// TODO: maybe move encryption/decryption into helper?
-// AES + Base64 encryption using ENCRYPTION_SECRET in .env as key
+// gcmNonceSize is the standard nonce size for AES-GCM.
+const gcmNonceSize = 12
+
+// Encrypt AES-GCM encrypts plaintext using ENCRYPTION_SECRET, then
base64-encodes the result.
+// The output format is: base64(nonce || ciphertext || tag).
func Encrypt(encryptionSecret, plainText string) (string, errors.Error) {
// add suffix to the data part
inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102,
125)
@@ -45,7 +50,8 @@ func Encrypt(encryptionSecret, plainText string) (string,
errors.Error) {
return base64.StdEncoding.EncodeToString(output), nil
}
-// Base64 + AES decryption using ENCRYPTION_SECRET in .env as key
+// Decrypt base64-decodes then AES-GCM decrypts ciphertext using
ENCRYPTION_SECRET.
+// For backward compatibility, it also attempts AES-CBC decryption if the data
looks like legacy format.
func Decrypt(encryptionSecret, encryptedText string) (string, errors.Error) {
// when encryption key is not set
if encryptionSecret == "" {
@@ -98,41 +104,59 @@ func PKCS7UnPadding(origData []byte) []byte {
return origData[:(length - unpadding)]
}
-// AesEncrypt AES encryption, CBC
+// AesEncrypt AES-256-GCM encrypts origData using key.
+// The returned bytes are: nonce (12 bytes) || ciphertext || tag.
func AesEncrypt(origData, key []byte) ([]byte, errors.Error) {
- // data alignment fill and encryption
sha256Key := sha256.Sum256(key)
- key = sha256Key[:]
- block, err := aes.NewCipher(key)
+ block, err := aes.NewCipher(sha256Key[:])
if err != nil {
return nil, errors.Convert(err)
}
- // data alignment fill and encryption
- blockSize := block.BlockSize()
- origData = PKCS7Padding(origData, blockSize)
- blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
- crypted := make([]byte, len(origData))
- blockMode.CryptBlocks(crypted, origData)
- return crypted, nil
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ nonce := make([]byte, gcmNonceSize)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return nil, errors.Convert(err)
+ }
+ ciphertext := gcm.Seal(nonce, nonce, origData, nil)
+ return ciphertext, nil
}
-// AesDecrypt AES decryption
+// AesDecrypt decrypts crypted data using key.
+// It first tries AES-256-GCM (expects a 12-byte nonce prefix).
+// If that fails and the data length is a multiple of the AES block size
(legacy CBC format),
+// it falls back to AES-256-CBC for backward compatibility.
func AesDecrypt(crypted, key []byte) ([]byte, errors.Error) {
- // Uniformly use sha256 to process as 32-bit Byte (256-bit bit)
sha256Key := sha256.Sum256(key)
- key = sha256Key[:]
- block, err := aes.NewCipher(key)
+ block, err := aes.NewCipher(sha256Key[:])
if err != nil {
return nil, errors.Convert(err)
}
- // Get the block size and check whether the ciphertext length is legal
blockSize := block.BlockSize()
+
+ // Try GCM first if the data is long enough to contain a nonce.
+ if len(crypted) >= gcmNonceSize+blockSize {
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ nonce := crypted[:gcmNonceSize]
+ ciphertext := crypted[gcmNonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err == nil {
+ return plaintext, nil
+ }
+ // GCM decryption failed; fall through to try legacy CBC.
+ }
+
+ // Legacy CBC fallback.
if len(crypted)%blockSize != 0 {
return nil, errors.Default.New(fmt.Sprintf("The length of the
data to be decrypted is [%d], so cannot match the required block size [%d]",
len(crypted), blockSize))
}
- // Decrypt and unalign data
- blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
+ blockMode := cipher.NewCBCDecrypter(block, sha256Key[:blockSize])
origData := make([]byte, len(crypted))
blockMode.CryptBlocks(origData, crypted)
origData = PKCS7UnPadding(origData)
diff --git a/backend/core/plugin/plugin_utils_test.go
b/backend/core/plugin/plugin_utils_test.go
index b42a4c57b..9a8f516ad 100644
--- a/backend/core/plugin/plugin_utils_test.go
+++ b/backend/core/plugin/plugin_utils_test.go
@@ -18,8 +18,13 @@ limitations under the License.
package plugin
import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha256"
+ "encoding/base64"
"testing"
+ "github.com/apache/incubator-devlake/core/errors"
"github.com/stretchr/testify/assert"
)
@@ -70,3 +75,67 @@ func TestEncode(t *testing.T) {
})
}
}
+
+func TestGCMEncDec(t *testing.T) {
+ TestStr := "The string for testing"
+ encryptionSecret, _ := RandomEncryptionSecret()
+
+ // Encrypt with the new GCM format.
+ newCiphertext, err := Encrypt(encryptionSecret, TestStr)
+ assert.Empty(t, err)
+
+ // Decrypt the new format.
+ decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
+ assert.Empty(t, err)
+ assert.Equal(t, TestStr, decodedNew)
+
+ // Ensure two encryptions of the same plaintext produce different
ciphertexts (random nonce).
+ newCiphertext2, err := Encrypt(encryptionSecret, TestStr)
+ assert.Empty(t, err)
+ assert.NotEqual(t, newCiphertext, newCiphertext2)
+}
+
+// AesEncrypt AES encryption, CBC
+func oldAesEncrypt(origData, key []byte) ([]byte, errors.Error) {
+ // data alignment fill and encryption
+ sha256Key := sha256.Sum256(key)
+ key = sha256Key[:]
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ // data alignment fill and encryption
+ blockSize := block.BlockSize()
+ origData = PKCS7Padding(origData, blockSize)
+ blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
+ crypted := make([]byte, len(origData))
+ blockMode.CryptBlocks(crypted, origData)
+ return crypted, nil
+}
+
+func oldEncrypt(encryptionSecret, plainText string) (string, errors.Error) {
+ // add suffix to the data part
+ inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102,
125)
+ // perform encryption
+ output, err := oldAesEncrypt(inputBytes, []byte(encryptionSecret))
+ if err != nil {
+ return plainText, err
+ }
+ // Return the result after Base64 processing
+ return base64.StdEncoding.EncodeToString(output), nil
+}
+
+func TestBackwardCompatibility(t *testing.T) {
+ TestStr := "The string for testing"
+ encryptionSecret, _ := RandomEncryptionSecret()
+
+ // Encrypt with the new GCM format.
+ newCiphertext, err := oldEncrypt(encryptionSecret, TestStr)
+ assert.Empty(t, err)
+
+ // Decrypt the new format.
+ decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
+ assert.Empty(t, err)
+ assert.Equal(t, TestStr, decodedNew)
+
+}