Skip to content

Commit 5e9dfb8

Browse files
kleshKlesh Wong
andauthored
feat(encryption): implement AES-GCM encryption and decryption with backward compatibility (#8895)
Co-authored-by: Klesh Wong <kleshwong@gmail.com>
1 parent 92fbab8 commit 5e9dfb8

2 files changed

Lines changed: 114 additions & 21 deletions

File tree

backend/core/plugin/plugin_utils.go

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,23 @@ import (
2121
"bytes"
2222
"crypto/aes"
2323
"crypto/cipher"
24+
"crypto/rand"
2425
"crypto/sha256"
2526
"encoding/base64"
2627
"fmt"
28+
"io"
2729

2830
"github.com/apache/incubator-devlake/core/errors"
2931
"github.com/apache/incubator-devlake/core/utils"
3032
)
3133

3234
const EncodeKeyEnvStr = "ENCRYPTION_SECRET"
3335

34-
// TODO: maybe move encryption/decryption into helper?
35-
// AES + Base64 encryption using ENCRYPTION_SECRET in .env as key
36+
// gcmNonceSize is the standard nonce size for AES-GCM.
37+
const gcmNonceSize = 12
38+
39+
// Encrypt AES-GCM encrypts plaintext using ENCRYPTION_SECRET, then base64-encodes the result.
40+
// The output format is: base64(nonce || ciphertext || tag).
3641
func Encrypt(encryptionSecret, plainText string) (string, errors.Error) {
3742
// add suffix to the data part
3843
inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102, 125)
@@ -45,7 +50,8 @@ func Encrypt(encryptionSecret, plainText string) (string, errors.Error) {
4550
return base64.StdEncoding.EncodeToString(output), nil
4651
}
4752

48-
// Base64 + AES decryption using ENCRYPTION_SECRET in .env as key
53+
// Decrypt base64-decodes then AES-GCM decrypts ciphertext using ENCRYPTION_SECRET.
54+
// For backward compatibility, it also attempts AES-CBC decryption if the data looks like legacy format.
4955
func Decrypt(encryptionSecret, encryptedText string) (string, errors.Error) {
5056
// when encryption key is not set
5157
if encryptionSecret == "" {
@@ -98,41 +104,59 @@ func PKCS7UnPadding(origData []byte) []byte {
98104
return origData[:(length - unpadding)]
99105
}
100106

101-
// AesEncrypt AES encryption, CBC
107+
// AesEncrypt AES-256-GCM encrypts origData using key.
108+
// The returned bytes are: nonce (12 bytes) || ciphertext || tag.
102109
func AesEncrypt(origData, key []byte) ([]byte, errors.Error) {
103-
// data alignment fill and encryption
104110
sha256Key := sha256.Sum256(key)
105-
key = sha256Key[:]
106-
block, err := aes.NewCipher(key)
111+
block, err := aes.NewCipher(sha256Key[:])
107112
if err != nil {
108113
return nil, errors.Convert(err)
109114
}
110-
// data alignment fill and encryption
111-
blockSize := block.BlockSize()
112-
origData = PKCS7Padding(origData, blockSize)
113-
blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
114-
crypted := make([]byte, len(origData))
115-
blockMode.CryptBlocks(crypted, origData)
116-
return crypted, nil
115+
gcm, err := cipher.NewGCM(block)
116+
if err != nil {
117+
return nil, errors.Convert(err)
118+
}
119+
nonce := make([]byte, gcmNonceSize)
120+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
121+
return nil, errors.Convert(err)
122+
}
123+
ciphertext := gcm.Seal(nonce, nonce, origData, nil)
124+
return ciphertext, nil
117125
}
118126

119-
// AesDecrypt AES decryption
127+
// AesDecrypt decrypts crypted data using key.
128+
// It first tries AES-256-GCM (expects a 12-byte nonce prefix).
129+
// If that fails and the data length is a multiple of the AES block size (legacy CBC format),
130+
// it falls back to AES-256-CBC for backward compatibility.
120131
func AesDecrypt(crypted, key []byte) ([]byte, errors.Error) {
121-
// Uniformly use sha256 to process as 32-bit Byte (256-bit bit)
122132
sha256Key := sha256.Sum256(key)
123-
key = sha256Key[:]
124-
block, err := aes.NewCipher(key)
133+
block, err := aes.NewCipher(sha256Key[:])
125134
if err != nil {
126135
return nil, errors.Convert(err)
127136
}
128-
// Get the block size and check whether the ciphertext length is legal
129137
blockSize := block.BlockSize()
138+
139+
// Try GCM first if the data is long enough to contain a nonce.
140+
if len(crypted) >= gcmNonceSize+blockSize {
141+
gcm, err := cipher.NewGCM(block)
142+
if err != nil {
143+
return nil, errors.Convert(err)
144+
}
145+
nonce := crypted[:gcmNonceSize]
146+
ciphertext := crypted[gcmNonceSize:]
147+
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
148+
if err == nil {
149+
return plaintext, nil
150+
}
151+
// GCM decryption failed; fall through to try legacy CBC.
152+
}
153+
154+
// Legacy CBC fallback.
130155
if len(crypted)%blockSize != 0 {
131156
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))
132157
}
133158

134-
// Decrypt and unalign data
135-
blockMode := cipher.NewCBCDecrypter(block, key[:blockSize])
159+
blockMode := cipher.NewCBCDecrypter(block, sha256Key[:blockSize])
136160
origData := make([]byte, len(crypted))
137161
blockMode.CryptBlocks(origData, crypted)
138162
origData = PKCS7UnPadding(origData)

backend/core/plugin/plugin_utils_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ limitations under the License.
1818
package plugin
1919

2020
import (
21+
"crypto/aes"
22+
"crypto/cipher"
23+
"crypto/sha256"
24+
"encoding/base64"
2125
"testing"
2226

27+
"github.com/apache/incubator-devlake/core/errors"
2328
"github.com/stretchr/testify/assert"
2429
)
2530

@@ -70,3 +75,67 @@ func TestEncode(t *testing.T) {
7075
})
7176
}
7277
}
78+
79+
func TestGCMEncDec(t *testing.T) {
80+
TestStr := "The string for testing"
81+
encryptionSecret, _ := RandomEncryptionSecret()
82+
83+
// Encrypt with the new GCM format.
84+
newCiphertext, err := Encrypt(encryptionSecret, TestStr)
85+
assert.Empty(t, err)
86+
87+
// Decrypt the new format.
88+
decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
89+
assert.Empty(t, err)
90+
assert.Equal(t, TestStr, decodedNew)
91+
92+
// Ensure two encryptions of the same plaintext produce different ciphertexts (random nonce).
93+
newCiphertext2, err := Encrypt(encryptionSecret, TestStr)
94+
assert.Empty(t, err)
95+
assert.NotEqual(t, newCiphertext, newCiphertext2)
96+
}
97+
98+
// AesEncrypt AES encryption, CBC
99+
func oldAesEncrypt(origData, key []byte) ([]byte, errors.Error) {
100+
// data alignment fill and encryption
101+
sha256Key := sha256.Sum256(key)
102+
key = sha256Key[:]
103+
block, err := aes.NewCipher(key)
104+
if err != nil {
105+
return nil, errors.Convert(err)
106+
}
107+
// data alignment fill and encryption
108+
blockSize := block.BlockSize()
109+
origData = PKCS7Padding(origData, blockSize)
110+
blockMode := cipher.NewCBCEncrypter(block, key[:blockSize])
111+
crypted := make([]byte, len(origData))
112+
blockMode.CryptBlocks(crypted, origData)
113+
return crypted, nil
114+
}
115+
116+
func oldEncrypt(encryptionSecret, plainText string) (string, errors.Error) {
117+
// add suffix to the data part
118+
inputBytes := append([]byte(plainText), 123, 110, 100, 100, 116, 102, 125)
119+
// perform encryption
120+
output, err := oldAesEncrypt(inputBytes, []byte(encryptionSecret))
121+
if err != nil {
122+
return plainText, err
123+
}
124+
// Return the result after Base64 processing
125+
return base64.StdEncoding.EncodeToString(output), nil
126+
}
127+
128+
func TestBackwardCompatibility(t *testing.T) {
129+
TestStr := "The string for testing"
130+
encryptionSecret, _ := RandomEncryptionSecret()
131+
132+
// Encrypt with the new GCM format.
133+
newCiphertext, err := oldEncrypt(encryptionSecret, TestStr)
134+
assert.Empty(t, err)
135+
136+
// Decrypt the new format.
137+
decodedNew, err := Decrypt(encryptionSecret, newCiphertext)
138+
assert.Empty(t, err)
139+
assert.Equal(t, TestStr, decodedNew)
140+
141+
}

0 commit comments

Comments
 (0)