diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java index 05fe43c60..f88fbcf2d 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/DataAgentApplication.java @@ -15,11 +15,14 @@ */ package com.alibaba.cloud.ai.dataagent; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling +@EnableConfigurationProperties(EncryptionProperties.class) @SpringBootApplication public class DataAgentApplication { diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java new file mode 100644 index 000000000..9a7a03954 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/config/EncryptionProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2024-2026 the original author or 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Encryption configuration properties for sensitive data storage. + * + *
+ * The encryption key must be a 32-byte AES key encoded as Base64. Set via environment + * variable {@code DATA_AGENT_ENCRYPT_KEY} or configuration property + * {@code spring.ai.alibaba.data-agent.encrypt-key}. + *
+ * + *+ * Backward Compatibility: When {@code encrypt-key} is not configured + * (empty/null), encryption is completely disabled and the system behaves identically to + * the pre-encryption version. This allows: + *
+ *+ * To generate a new key: {@code openssl rand -base64 32} + *
+ */ +@Data +@ConfigurationProperties(prefix = "spring.ai.alibaba.data-agent") +public class EncryptionProperties { + + /** + * Base64-encoded 32-byte AES key for encrypting sensitive data. If not set, + * encryption is disabled (NOT recommended for production). + */ + private String encryptKey; + + /** + * Check if encryption is enabled (key is configured). + * @return true if encryptKey is set and non-empty + */ + public boolean isEncryptionEnabled() { + return encryptKey != null && !encryptKey.isEmpty(); + } + +} diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java index bac38186e..02e4bd404 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImpl.java @@ -15,11 +15,13 @@ */ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.enums.ModelType; import com.alibaba.cloud.ai.dataagent.converter.ModelConfigConverter; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; import com.alibaba.cloud.ai.dataagent.mapper.ModelConfigMapper; +import com.alibaba.cloud.ai.dataagent.util.CryptoUtil; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -39,9 +41,15 @@ public class ModelConfigDataServiceImpl implements ModelConfigDataService { private final ModelConfigMapper modelConfigMapper; + private final EncryptionProperties encryptionProperties; + @Override public ModelConfig findById(Integer id) { - return modelConfigMapper.findById(id); + ModelConfig config = modelConfigMapper.findById(id); + if (config != null) { + decryptModelConfig(config); + } + return config; } @Transactional(rollbackFor = Exception.class) @@ -61,14 +69,21 @@ public void switchActiveStatus(Integer id, ModelType type) { @Override public List+ * This method is called during add/update operations. When encryption is enabled, + * apiKey and proxyPassword are encrypted before being saved to database. + *
+ * @param config the model config to encrypt + * @see CryptoUtil#encrypt(String, String) + */ + private void encryptModelConfig(ModelConfig config) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, skip (backward compatible) + return; + } + String key = encryptionProperties.getEncryptKey(); + if (config.getApiKey() != null && !config.getApiKey().isEmpty()) { + config.setApiKey(CryptoUtil.encrypt(config.getApiKey(), key)); + } + if (config.getProxyPassword() != null && !config.getProxyPassword().isEmpty()) { + config.setProxyPassword(CryptoUtil.encrypt(config.getProxyPassword(), key)); + } + } + + /** + * Decrypt sensitive fields of a model config after loading from database. + * + *+ * Backward Compatibility: Uses + * {@link CryptoUtil#decryptOrReturn(String, String)} to handle both encrypted and + * plaintext data gracefully: + *
+ *+ * This ensures a smooth upgrade path: after enabling encryption, existing plaintext + * data remains readable, and new data will be encrypted on write. + *
+ * @param config the model config to decrypt + * @see CryptoUtil#decryptOrReturn(String, String) + */ + private void decryptModelConfig(ModelConfig config) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, data is plaintext, skip + return; + } + String key = encryptionProperties.getEncryptKey(); + if (config.getApiKey() != null && !config.getApiKey().isEmpty()) { + config.setApiKey(CryptoUtil.decryptOrReturn(config.getApiKey(), key)); + } + if (config.getProxyPassword() != null && !config.getProxyPassword().isEmpty()) { + config.setProxyPassword(CryptoUtil.decryptOrReturn(config.getProxyPassword(), key)); + } + } + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java index 7f7c4820a..7385cd6c0 100644 --- a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImpl.java @@ -24,6 +24,7 @@ import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPool; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPoolFactory; import com.alibaba.cloud.ai.dataagent.entity.AgentDatasource; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.entity.Datasource; import com.alibaba.cloud.ai.dataagent.entity.LogicalRelation; import com.alibaba.cloud.ai.dataagent.enums.ErrorCodeEnum; @@ -33,6 +34,7 @@ import com.alibaba.cloud.ai.dataagent.service.datasource.DatasourceService; import com.alibaba.cloud.ai.dataagent.service.datasource.handler.DatasourceTypeHandler; import com.alibaba.cloud.ai.dataagent.service.datasource.handler.registry.DatasourceTypeHandlerRegistry; +import com.alibaba.cloud.ai.dataagent.util.CryptoUtil; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -64,24 +66,36 @@ public class DatasourceServiceImpl implements DatasourceService { private final DatasourceTypeHandlerRegistry datasourceTypeHandlerRegistry; + private final EncryptionProperties encryptionProperties; + @Override public List+ * Backward Compatibility: Uses + * {@link CryptoUtil#decryptOrReturn(String, String)} to handle both encrypted and + * plaintext data gracefully: + *
+ *+ * This ensures a smooth upgrade path: after enabling encryption, existing plaintext + * data remains readable, and new data will be encrypted on write. + *
+ * @param datasource the datasource to decrypt + * @see CryptoUtil#decryptOrReturn(String, String) + */ + private void decryptDatasourcePassword(Datasource datasource) { + if (!encryptionProperties.isEncryptionEnabled()) { + // Encryption not configured, data is plaintext, skip + return; + } + String key = encryptionProperties.getEncryptKey(); + if (datasource.getPassword() != null && !datasource.getPassword().isEmpty()) { + datasource.setPassword(CryptoUtil.decryptOrReturn(datasource.getPassword(), key)); + } + if (datasource.getConnectionUrl() != null && !datasource.getConnectionUrl().isEmpty()) { + datasource.setConnectionUrl(CryptoUtil.decryptOrReturn(datasource.getConnectionUrl(), key)); + } + } + } diff --git a/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java new file mode 100644 index 000000000..9b15a0e39 --- /dev/null +++ b/data-agent-management/src/main/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtil.java @@ -0,0 +1,225 @@ +/* + * Copyright 2024-2026 the original author or 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.util; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * AES-256-GCM encryption/decryption utility for sensitive data storage. + * + *+ * Encryption key must be 32 bytes, provided as Base64-encoded string via environment + * variable {@code DATA_AGENT_ENCRYPT_KEY}. + *
+ * + *+ * Ciphertext format: {@code Base64(iv + encryptedData + authTag)}, where iv is 12 bytes, + * authTag is 16 bytes. + *
+ */ +public final class CryptoUtil { + + private static final String ALGORITHM = "AES"; + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final int GCM_IV_LENGTH = 12; + + private static final int GCM_TAG_LENGTH = 128; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private CryptoUtil() { + } + + /** + * Encrypt plaintext using AES-256-GCM. + * @param plaintext the text to encrypt + * @param keyBase64 Base64-encoded 32-byte AES key + * @return Base64-encoded ciphertext, or null/empty if input is null/empty + */ + public static String encrypt(String plaintext, String keyBase64) { + if (plaintext == null || plaintext.isEmpty()) { + return plaintext; + } + if (keyBase64 == null || keyBase64.isEmpty()) { + throw new IllegalArgumentException("Encryption key must not be null or empty"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + + byte[] iv = new byte[GCM_IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec); + + byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // Pack iv + ciphertext into single byte array + ByteBuffer buffer = ByteBuffer.allocate(iv.length + encrypted.length); + buffer.put(iv); + buffer.put(encrypted); + + return Base64.getEncoder().encodeToString(buffer.array()); + } + catch (Exception e) { + throw new RuntimeException("Failed to encrypt data", e); + } + } + + /** + * Decrypt ciphertext using AES-256-GCM. + * @param ciphertext Base64-encoded ciphertext from {@link #encrypt(String, String)} + * @param keyBase64 Base64-encoded 32-byte AES key + * @return decrypted plaintext, or null/empty if input is null/empty + */ + public static String decrypt(String ciphertext, String keyBase64) { + if (ciphertext == null || ciphertext.isEmpty()) { + return ciphertext; + } + if (keyBase64 == null || keyBase64.isEmpty()) { + throw new IllegalArgumentException("Encryption key must not be null or empty"); + } + try { + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + + byte[] decoded = Base64.getDecoder().decode(ciphertext); + ByteBuffer buffer = ByteBuffer.wrap(decoded); + + byte[] iv = new byte[GCM_IV_LENGTH]; + buffer.get(iv); + + byte[] encrypted = new byte[buffer.remaining()]; + buffer.get(encrypted); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); + + byte[] decrypted = cipher.doFinal(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + } + catch (Exception e) { + throw new RuntimeException("Failed to decrypt data", e); + } + } + + /** + * Generate a random 32-byte AES key, returned as Base64 string. Useful for initial + * setup. + * @return Base64-encoded 32-byte key + */ + public static String generateKey() { + byte[] key = new byte[32]; + SECURE_RANDOM.nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + + /** + * Check if a ciphertext string is already encrypted (Base64-encoded with valid + * length). This is a heuristic check, not cryptographic verification. + * @param value the string to check + * @return true if the value appears to be encrypted + */ + public static boolean isEncrypted(String value) { + if (value == null || value.isEmpty()) { + return false; + } + try { + byte[] decoded = Base64.getDecoder().decode(value); + // Minimum: 12 (iv) + 16 (tag) + 1 (data) = 29 bytes + return decoded.length >= GCM_IV_LENGTH + GCM_TAG_LENGTH / 8 + 1; + } + catch (IllegalArgumentException e) { + return false; + } + } + + /** + * Decrypt ciphertext if it's encrypted, otherwise return as-is. This method provides + * backward compatibility for data that was stored before encryption was enabled. + * + *+ * Backward Compatibility Strategy: + *
+ *+ * Detection Logic: Uses {@link #isEncrypted(String)} to check if the + * value is valid Base64-encoded ciphertext with minimum GCM structure (12-byte IV + + * 16-byte auth tag + at least 1 byte data = 29 bytes minimum). + *
+ * @param value the value to decrypt (may be encrypted or plaintext), null-safe + * @param keyBase64 Base64-encoded 32-byte AES key + * @return decrypted value if encrypted, or original value if plaintext/null/empty + */ + public static String decryptOrReturn(String value, String keyBase64) { + if (value == null || value.isEmpty()) { + return value; + } + // Check if the value looks like encrypted data before attempting decryption + // This prevents decryption failures on legacy plaintext data + if (isEncrypted(value)) { + try { + return decrypt(value, keyBase64); + } + catch (RuntimeException e) { + // If decryption fails (e.g., wrong key), return original value + // This handles edge cases where plaintext data happens to pass + // isEncrypted check + return value; + } + } + // Not encrypted, return as-is (backward compatible with historical plaintext + // data) + return value; + } + + /** + * Main method for generating encryption key from command line. + * @param args unused + */ + public static void main(String[] args) { + System.out.println("=== DataAgent Encryption Key Generator ==="); + System.out.println(); + String key = generateKey(); + System.out.println("Generated AES-256 Key (Base64): " + key); + System.out.println(); + System.out.println("Set this as environment variable:"); + System.out.println(" Linux/Mac: export DATA_AGENT_ENCRYPT_KEY=" + key); + System.out.println(" Windows: set DATA_AGENT_ENCRYPT_KEY=" + key); + System.out.println(" PowerShell: $env:DATA_AGENT_ENCRYPT_KEY=\"" + key + "\""); + } + +} diff --git a/data-agent-management/src/main/resources/application-h2.yml b/data-agent-management/src/main/resources/application-h2.yml index ac6c78d46..ad5916038 100644 --- a/data-agent-management/src/main/resources/application-h2.yml +++ b/data-agent-management/src/main/resources/application-h2.yml @@ -55,6 +55,9 @@ spring: endpoint: ${OSS_ENDPOINT:} bucket-name: ${OSS_BUCKET_NAME:} custom-domain: ${OSS_CUSTOM_DOMAIN:} + # 敏感数据加密密钥(AES-256-GCM,Base64编码的32字节密钥) + # 开发环境使用默认测试密钥,生产环境必须通过环境变量设置 + encrypt-key: ${DATA_AGENT_ENCRYPT_KEY:dGVzdEVuY3J5cHRpb25LZXlGb3JEZXYxMjM0NTY3ODkw} max-sql-retry-count: 10 # Langfuse 可观测性配置 langfuse: diff --git a/data-agent-management/src/main/resources/application.yml b/data-agent-management/src/main/resources/application.yml index 30f606553..775baa55b 100644 --- a/data-agent-management/src/main/resources/application.yml +++ b/data-agent-management/src/main/resources/application.yml @@ -52,6 +52,9 @@ spring: endpoint: ${OSS_ENDPOINT:} bucket-name: ${OSS_BUCKET_NAME:} custom-domain: ${OSS_CUSTOM_DOMAIN:} + # 敏感数据加密密钥(AES-256-GCM,Base64编码的32字节密钥) + # 生产环境必须通过环境变量 DATA_AGENT_ENCRYPT_KEY 设置 + encrypt-key: ${DATA_AGENT_ENCRYPT_KEY:} max-sql-retry-count: 10 # Langfuse 可观测性配置 langfuse: diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java index ddfccfd08..55a298b7d 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/aimodelconfig/ModelConfigDataServiceImplTest.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.dataagent.service.aimodelconfig; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.dto.ModelConfigDTO; import com.alibaba.cloud.ai.dataagent.entity.ModelConfig; import com.alibaba.cloud.ai.dataagent.enums.ModelType; @@ -30,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class ModelConfigDataServiceImplTest { @@ -39,9 +41,14 @@ class ModelConfigDataServiceImplTest { @Mock private ModelConfigMapper modelConfigMapper; + @Mock + private EncryptionProperties encryptionProperties; + @BeforeEach void setUp() { - service = new ModelConfigDataServiceImpl(modelConfigMapper); + // Disable encryption by default in tests + lenient().when(encryptionProperties.isEncryptionEnabled()).thenReturn(false); + service = new ModelConfigDataServiceImpl(modelConfigMapper, encryptionProperties); } @Test diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java index 9e46534ab..67b4dd545 100644 --- a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/service/datasource/impl/DatasourceServiceImplTest.java @@ -15,6 +15,7 @@ */ package com.alibaba.cloud.ai.dataagent.service.datasource.impl; +import com.alibaba.cloud.ai.dataagent.config.EncryptionProperties; import com.alibaba.cloud.ai.dataagent.connector.accessor.AccessorFactory; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPool; import com.alibaba.cloud.ai.dataagent.connector.pool.DBConnectionPoolFactory; @@ -38,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class DatasourceServiceImplTest { @@ -68,10 +70,15 @@ class DatasourceServiceImplTest { @Mock private DBConnectionPool dbConnectionPool; + @Mock + private EncryptionProperties encryptionProperties; + @BeforeEach void setUp() { + // Disable encryption by default in tests + lenient().when(encryptionProperties.isEncryptionEnabled()).thenReturn(false); datasourceService = new DatasourceServiceImpl(datasourceMapper, agentDatasourceMapper, logicalRelationMapper, - poolFactory, accessorFactory, handlerRegistry); + poolFactory, accessorFactory, handlerRegistry, encryptionProperties); } @Test diff --git a/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java new file mode 100644 index 000000000..ed86f1434 --- /dev/null +++ b/data-agent-management/src/test/java/com/alibaba/cloud/ai/dataagent/util/CryptoUtilTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 2024-2026 the original author or 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 + * + * https://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 com.alibaba.cloud.ai.dataagent.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CryptoUtil}. + */ +class CryptoUtilTest { + + private String testKey; + + @BeforeEach + void setUp() { + // Generate a fresh key for each test + testKey = CryptoUtil.generateKey(); + } + + @Test + void testEncryptDecryptRoundTrip() { + String plaintext = "mySecretPassword123!"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + + assertNotEquals(plaintext, encrypted, "Ciphertext should differ from plaintext"); + assertEquals(plaintext, decrypted, "Decrypted text should match original"); + } + + @Test + void testEncryptNullReturnsNull() { + assertNull(CryptoUtil.encrypt(null, testKey)); + } + + @Test + void testEncryptEmptyReturnsEmpty() { + assertEquals("", CryptoUtil.encrypt("", testKey)); + } + + @Test + void testDecryptNullReturnsNull() { + assertNull(CryptoUtil.decrypt(null, testKey)); + } + + @Test + void testDecryptEmptyReturnsEmpty() { + assertEquals("", CryptoUtil.decrypt("", testKey)); + } + + @Test + void testEncryptProducesDifferentCiphertextEachTime() { + String plaintext = "samePassword"; + String encrypted1 = CryptoUtil.encrypt(plaintext, testKey); + String encrypted2 = CryptoUtil.encrypt(plaintext, testKey); + + // IV is random, so ciphertexts should differ + assertNotEquals(encrypted1, encrypted2, "Same plaintext should produce different ciphertexts due to random IV"); + } + + @Test + void testDecryptWithWrongKeyFails() { + String plaintext = "myPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String wrongKey = CryptoUtil.generateKey(); + + assertThrows(RuntimeException.class, () -> CryptoUtil.decrypt(encrypted, wrongKey)); + } + + @Test + void testDecryptTamperedCiphertextFails() { + String plaintext = "myPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + // Tamper with the ciphertext + String tampered = encrypted.substring(0, encrypted.length() - 2) + "XX"; + + assertThrows(RuntimeException.class, () -> CryptoUtil.decrypt(tampered, testKey)); + } + + @Test + void testEncryptWithNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.encrypt("test", null)); + } + + @Test + void testEncryptWithEmptyKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.encrypt("test", "")); + } + + @Test + void testDecryptWithNullKeyThrows() { + assertThrows(IllegalArgumentException.class, () -> CryptoUtil.decrypt("test", null)); + } + + @Test + void testGenerateKeyProducesValidBase64() { + String key = CryptoUtil.generateKey(); + // Base64 encoded 32 bytes should be 44 characters + assertEquals(44, key.length()); + // Should not throw when decoding + java.util.Base64.getDecoder().decode(key); + } + + @Test + void testIsEncryptedWithValidCiphertext() { + String encrypted = CryptoUtil.encrypt("test", testKey); + assertTrue(CryptoUtil.isEncrypted(encrypted)); + } + + @Test + void testIsEncryptedWithPlaintext() { + assertFalse(CryptoUtil.isEncrypted("plaintext")); + assertFalse(CryptoUtil.isEncrypted("password123")); + } + + @Test + void testIsEncryptedWithNull() { + assertFalse(CryptoUtil.isEncrypted(null)); + } + + @Test + void testIsEncryptedWithEmpty() { + assertFalse(CryptoUtil.isEncrypted("")); + } + + @Test + void testEncryptDecryptSpecialCharacters() { + String plaintext = "p@$$w0rd!#$%^&*()_+-=[]{}|;':\",./<>?"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptUnicode() { + String plaintext = "密码测试🔐🔑"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptLongString() { + String plaintext = "A".repeat(10000); + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(plaintext, decrypted); + } + + @Test + void testEncryptDecryptConnectionString() { + String url = "jdbc:mysql://192.168.1.100:3306/mydb?useSSL=true&password=secret123"; + String encrypted = CryptoUtil.encrypt(url, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(url, decrypted); + } + + @Test + void testEncryptDecryptApiKey() { + String apiKey = "sk-abcdefghijklmnopqrstuvwxyz123456"; + String encrypted = CryptoUtil.encrypt(apiKey, testKey); + String decrypted = CryptoUtil.decrypt(encrypted, testKey); + assertEquals(apiKey, decrypted); + } + + // Tests for decryptOrReturn (backward compatibility) + + @Test + void testDecryptOrReturn_withEncryptedData_decrypts() { + String plaintext = "mySecretPassword"; + String encrypted = CryptoUtil.encrypt(plaintext, testKey); + + String result = CryptoUtil.decryptOrReturn(encrypted, testKey); + assertEquals(plaintext, result); + } + + @Test + void testDecryptOrReturn_withPlaintext_returnsAsIs() { + String plaintext = "oldPlainTextPassword"; + + String result = CryptoUtil.decryptOrReturn(plaintext, testKey); + assertEquals(plaintext, result); + } + + @Test + void testDecryptOrReturn_withNull_returnsNull() { + assertNull(CryptoUtil.decryptOrReturn(null, testKey)); + } + + @Test + void testDecryptOrReturn_withEmpty_returnsEmpty() { + assertEquals("", CryptoUtil.decryptOrReturn("", testKey)); + } + + @Test + void testDecryptOrReturn_withConnectionString() { + String url = "jdbc:mysql://localhost:3306/test?user=root&password=123456"; + + // Plaintext URL should pass through + String result = CryptoUtil.decryptOrReturn(url, testKey); + assertEquals(url, result); + } + + @Test + void testDecryptOrReturn_withEncryptedConnectionString() { + String url = "jdbc:mysql://localhost:3306/test?user=root&password=123456"; + String encrypted = CryptoUtil.encrypt(url, testKey); + + String result = CryptoUtil.decryptOrReturn(encrypted, testKey); + assertEquals(url, result); + } + +} diff --git a/docs/DEVELOPER_GUIDE-en.md b/docs/DEVELOPER_GUIDE-en.md index 3cb13f4ff..d26b94a2c 100644 --- a/docs/DEVELOPER_GUIDE-en.md +++ b/docs/DEVELOPER_GUIDE-en.md @@ -379,6 +379,80 @@ Environment variables: `LANGFUSE_ENABLED`, `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY > For detailed usage, refer to [Advanced Features - Langfuse Observability](ADVANCED_FEATURES-en.md#langfuse-observability). +### 12. Data Encryption Configuration + +Configuration prefix: `spring.ai.alibaba.data-agent` + +The system supports AES-256-GCM encryption for sensitive information (datasource passwords, API keys, etc.). + +| Configuration Item | Description | Default Value | +|-------------------|-------------|---------------| +| `encrypt-key` | AES-256 encryption key (Base64-encoded 32-byte key) | Empty (no encryption) | + +Environment variable: `DATA_AGENT_ENCRYPT_KEY` + +#### Protected Fields + +| Database Table | Field | Description | +|----------------|-------|-------------| +| `datasource` | `password` | Database connection password | +| `datasource` | `connection_url` | Database connection URL (may contain password) | +| `model_config` | `api_key` | LLM API key | +| `model_config` | `proxy_password` | Proxy server password | + +#### Usage + +1. **Generate encryption key** + ```bash + # Option 1: Use built-in utility (requires Java 17+) + java -cp data-agent-management/target/spring-ai-alibaba-data-agent-management-${revision}.jar.original com.alibaba.cloud.ai.dataagent.util.CryptoUtil + + # Option 2: Use OpenSSL (Recommended) + openssl rand -base64 32 + ``` + + > `${revision}` is the project version, currently `1.0.0-SNAPSHOT`. See `