This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12403-b0dd4945b1da8423a481b053082bd5fd11b9533f
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit d051216fc2e253056c8871e7d89d4cfcda5ebb25
Author: chovygo <[email protected]>
AuthorDate: Sun Oct 4 23:00:11 2026 +0000

    [Feature][Transform-V2] Add AES_ENCRYPT/AES_DECRYPT SQL functions (#12403)
    
    Co-authored-by: zhaowei <[email protected]>
---
 .../introduction/concepts/incompatible-changes.md  |  18 +
 docs/en/transforms/sql-functions.md                |  44 ++
 .../introduction/concepts/incompatible-changes.md  |   8 +
 docs/zh/transforms/sql-functions.md                |  44 ++
 .../test/resources/sql_transform/func_string.conf  |  16 +-
 .../transform/sql/zeta/ZetaSQLFunction.java        |   9 +
 .../seatunnel/transform/sql/zeta/ZetaSQLType.java  |   2 +
 .../sql/zeta/functions/CryptoFunction.java         | 308 +++++++++++++
 .../transform/sql/SQLCryptoFunctionsTest.java      | 150 +++++++
 .../sql/zeta/functions/CryptoFunctionTest.java     | 479 +++++++++++++++++++++
 10 files changed, 1077 insertions(+), 1 deletion(-)

diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 79d75c8cee..340ebb8492 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -53,6 +53,24 @@ You need to check this document before you upgrade to 
related version.
 - To keep using the default user, remove `user` and retain `auth` when a 
password is required.
   Named users require Redis 6 or later. Legacy configurations without a 
username remain unchanged.
 
+### Zeta SQL Transform: built-in AES_ENCRYPT / AES_DECRYPT
+
+- **Behavior change: AES_ENCRYPT / AES_DECRYPT are now built-in functions**
+  - **Affected component**: `seatunnel-transforms-v2` (Zeta SQL transform).
+  - **Description**: `AES_ENCRYPT(value, key[, iv])` and `AES_DECRYPT(value, 
key[, iv])` are now
+    built-in Zeta SQL functions and are dispatched before user-registered 
`ZetaUDF`s. They use
+    `AES/CBC/PKCS5Padding` with Base64 output; without an explicit IV a random 
IV is generated and
+    prepended to the ciphertext so `AES_DECRYPT` can recover it without an 
explicit IV.
+  - **Impact**: A job that registered a custom `ZetaUDF` named `AES_ENCRYPT` 
or `AES_DECRYPT` (the
+    previous workaround for the missing built-in) will, after upgrading, 
silently start using this
+    built-in implementation instead of the UDF. If the UDF used a different 
key derivation, IV
+    handling or output encoding, ciphertext already written by the UDF may 
fail to decrypt (or,
+    roughly once in 256 for CBC padding, decrypt to garbage).
+  - **Migration Guide**: Rename the existing UDF, or switch to the built-in 
functions. To stay
+    wire-compatible with the `FieldEncrypt` `AesCbcEncryptor`, supply the key 
with the `base64:`
+    prefix (a bare key is derived as a passphrase via SHA-256 and is **not** 
interchangeable with
+    `FieldEncrypt`). See [SQL Functions](../../transforms/sql-functions.md) 
for the full contract.
+
 ### RabbitMQ Connector
 
 - **Breaking Change: `amqps://` connections now verify broker certificates**
diff --git a/docs/en/transforms/sql-functions.md 
b/docs/en/transforms/sql-functions.md
index 0b629187bc..a244f3bad0 100644
--- a/docs/en/transforms/sql-functions.md
+++ b/docs/en/transforms/sql-functions.md
@@ -1110,6 +1110,50 @@ local_date_time AT TIME ZONE '+09:00'
 
 offset_date_time AT TIME ZONE 'Pacific/Honolulu'
 
+## Crypto Functions
+
+### AES_ENCRYPT
+
+```AES_ENCRYPT(value, key[, iv]) -> STRING```
+
+Encrypts `value` with AES/CBC/PKCS5Padding and returns a Base64-encoded 
ciphertext. Returns **NULL** if `value` is **NULL**.
+
+- `value`: the plaintext to encrypt. Any non-null scalar value is converted to 
a string; array and map inputs are rejected.
+- `key`: the secret key. If it starts with `base64:`, the remainder is decoded 
as a raw AES key and must be 16, 24, or 32 bytes (AES-128/192/256); only this 
`base64:` form is wire-compatible with the `AesCbcEncryptor` used by the 
`FieldEncryptTransform`. Any other value is treated as a passphrase: it is 
hashed once with SHA-256 and the first 16 bytes are used as an AES-128 key, so 
arbitrary-length passphrases are supported. Passphrase mode is an unsalted fast 
KDF; for strong protection u [...]
+- `iv`: optional initialization vector. If provided, its UTF-8 bytes are used 
as the IV and must be exactly 16 bytes; the returned ciphertext contains only 
the encrypted bytes and the caller is responsible for storing the IV. If 
omitted, a random 16-byte IV is generated per call and prepended to the 
ciphertext, so `AES_DECRYPT` can recover it without an explicit IV. An explicit 
`NULL` IV is rejected (omit the argument instead).
+
+Example:
+
+AES_ENCRYPT(name, 'mySecretPass')
+
+AES_ENCRYPT(name, 'mySecretPass', '1234567890123456')
+
+AES_ENCRYPT(name, 'base64:MTIzNDU2Nzg5MDEyMzQ1Ng==')
+
+SELECT AES_DECRYPT(AES_ENCRYPT(name, 'mySecretPass'), 'mySecretPass') AS 
name_enc FROM dual
+
+NOTE:
+- CBC is an unauthenticated mode: a wrong key or a corrupted ciphertext may 
(about once in 256) decrypt to garbage instead of throwing. Do not rely on a 
decryption error to detect a wrong key.
+- When `iv` is omitted the ciphertext is non-deterministic (a fresh random IV 
is generated for each call). To get a deterministic ciphertext, provide an 
explicit `iv`.
+
+### AES_DECRYPT
+
+```AES_DECRYPT(value, key[, iv]) -> STRING```
+
+Decrypts a Base64 AES/CBC/PKCS5Padding ciphertext. Returns **NULL** if `value` 
is **NULL**.
+
+- `value`: the Base64-encoded ciphertext produced by `AES_ENCRYPT`.
+- `key`: the secret key, with the same conventions as `AES_ENCRYPT`. It must 
match the key used for encryption.
+- `iv`: optional initialization vector. When omitted, the first 16 bytes of 
the decoded payload are treated as the IV (the format produced by `AES_ENCRYPT` 
without an IV). When provided, its UTF-8 bytes are used as the IV (must be 16 
bytes) and the whole decoded payload is treated as the ciphertext. An explicit 
`NULL` IV is rejected.
+
+Example:
+
+AES_DECRYPT(cipher, 'mySecretPass')
+
+AES_DECRYPT(AES_ENCRYPT(name, 'mySecretPass'), 'mySecretPass')
+
+AES_DECRYPT(cipher, 'mySecretPass', '1234567890123456')
+
 ## System Functions
 
 ### CAST
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index bd67abb3c0..7facd5f3c2 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -25,6 +25,14 @@
 - 如需继续使用默认用户,请移除 `user`,并在需要密码时保留 `auth`。
   命名用户需要 Redis 6 或更新版本;未配置用户名的旧配置行为保持不变。
 
+### Zeta SQL Transform:内置 AES_ENCRYPT / AES_DECRYPT
+
+- **行为变更:AES_ENCRYPT / AES_DECRYPT 现为内置函数**
+  - **影响范围**:`seatunnel-transforms-v2`(Zeta SQL transform)。
+  - **变更说明**:`AES_ENCRYPT(value, key[, iv])` 与 `AES_DECRYPT(value, key[, iv])` 
现为内置 Zeta SQL 函数,且分发顺序在用户注册的 `ZetaUDF` 之前。使用 `AES/CBC/PKCS5Padding`,输出 
Base64;未显式提供 IV 时生成随机 IV 并拼接到密文头部,故 `AES_DECRYPT` 无需显式 IV 即可恢复。
+  - **影响**:若作业此前注册了名为 `AES_ENCRYPT` 或 `AES_DECRYPT` 的自定义 
`ZetaUDF`(此前缺少内置函数时的变通做法),升级后将静默改用此内置实现而非 UDF。若该 UDF 使用了不同的密钥派生、IV 处理或输出编码,则已由 
UDF 写入的密文可能无法解密(或在 CBC 填充校验以约 1/256 概率碰巧通过时解出垃圾)。
+  - **迁移指南**:重命名已有 UDF,或迁移到内置函数。如需与 `FieldEncrypt` 的 `AesCbcEncryptor` 
保持线兼容,请使用带 `base64:` 前缀的密钥(裸密钥会按口令经 SHA-256 派生,**不**与 `FieldEncrypt` 互通)。完整契约见 
[SQL 函数](../../transforms/sql-functions.md)。
+
 ### RabbitMQ Connector
 
 - **破坏性变更:`amqps://` 连接现在会校验 Broker 证书**
diff --git a/docs/zh/transforms/sql-functions.md 
b/docs/zh/transforms/sql-functions.md
index 81b11c2b1d..b7eac38fd1 100644
--- a/docs/zh/transforms/sql-functions.md
+++ b/docs/zh/transforms/sql-functions.md
@@ -1115,6 +1115,50 @@ local_date_time AT TIME ZONE '+09:00'
 
 offset_date_time AT TIME ZONE 'Pacific/Honolulu'
 
+## 加密函数
+
+### AES_ENCRYPT
+
+```AES_ENCRYPT(value, key[, iv]) -> STRING```
+
+使用 AES/CBC/PKCS5Padding 加密 `value`,返回 Base64 编码的密文。如果 `value` 为 **NULL**,返回 
**NULL**。
+
+- `value`:待加密的明文。任何非空标量值都会被转换为字符串;数组与 Map 输入会被拒绝。
+- `key`:密钥。如果以 `base64:` 开头,则剩余部分按 Base64 解码为原始 AES 密钥,长度必须为 16、24 或 32 字节(对应 
AES-128/192/256);只有 `base64:` 形式才与 `FieldEncryptTransform` 使用的 
`AesCbcEncryptor` 线兼容。其他值按口令处理:对其 UTF-8 字节做一次 SHA-256,取前 16 字节作为 AES-128 
密钥,因此支持任意长度的口令。口令模式是无盐的快速 KDF,安全性较低;生产环境请使用随机的 `base64:` 密钥并作为机密保管。`base64:` 
密钥与口令不可互换。
+- `iv`:可选的初始化向量。提供时,其 UTF-8 字节作为 IV 使用,必须恰好为 16 字节;返回的密文仅包含加密后的字节,调用方需自行保存 
IV。省略时,每次调用生成 16 字节随机 IV 并拼接到密文头部,`AES_DECRYPT` 无需显式 IV 即可恢复。显式传入 `NULL` 的 IV 
会被拒绝(请改为省略该参数)。
+
+示例:
+
+AES_ENCRYPT(name, 'mySecretPass')
+
+AES_ENCRYPT(name, 'mySecretPass', '1234567890123456')
+
+AES_ENCRYPT(name, 'base64:MTIzNDU2Nzg5MDEyMzQ1Ng==')
+
+SELECT AES_DECRYPT(AES_ENCRYPT(name, 'mySecretPass'), 'mySecretPass') AS 
name_enc FROM dual
+
+注意:
+- CBC 是无认证模式:密钥错误或密文损坏时,约 1/256 的概率会解出垃圾串而非报错,切勿依赖解密报错来判断密钥是否正确。
+- 省略 `iv` 时密文是非确定性的(每次调用都会生成新的随机 IV)。如需确定性密文,请显式提供 `iv`。
+
+### AES_DECRYPT
+
+```AES_DECRYPT(value, key[, iv]) -> STRING```
+
+解密 Base64 编码的 AES/CBC/PKCS5Padding 密文。如果 `value` 为 **NULL**,返回 **NULL**。
+
+- `value`:由 `AES_ENCRYPT` 生成的 Base64 密文。
+- `key`:密钥,约定与 `AES_ENCRYPT` 相同,必须与加密时使用的密钥一致。
+- `iv`:可选的初始化向量。省略时,取解码后前 16 字节作为 IV(即 `AES_ENCRYPT` 未提供 IV 时生成的格式)。提供时,其 
UTF-8 字节作为 IV(必须为 16 字节),整个解码负载视为密文。显式传入 `NULL` 的 IV 会被拒绝。
+
+示例:
+
+AES_DECRYPT(cipher, 'mySecretPass')
+
+AES_DECRYPT(AES_ENCRYPT(name, 'mySecretPass'), 'mySecretPass')
+
+AES_DECRYPT(cipher, 'mySecretPass', '1234567890123456')
+
 ## System Functions
 
 ### CAST
diff --git 
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_string.conf
 
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_string.conf
index 192b6229e4..883a79a604 100644
--- 
a/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_string.conf
+++ 
b/seatunnel-e2e/seatunnel-transforms-v2-e2e/seatunnel-transforms-v2-e2e-part-2/src/test/resources/sql_transform/func_string.conf
@@ -54,7 +54,7 @@ transform {
   sql {
     plugin_input = "fake"
     plugin_output = "fake1"
-    query = "select ascii(c1) as c1_1, ascii(c2) as c2_1, bit_length(c4) as 
c4_1, length(c4) as c4_2, octet_length(c4) as c4_3, char(c5) as c5_1, 
concat(c1,id,'!') as c1_2, hextoraw(c6) as c6_1, rawtohex(c7) as c7_1, 
insert(name,2,2,'**') as name1, lower(name) as name2, upper(name) as name3, 
left(name, 3) as name4, right(name, 4) as name5, lpad(name, 10, '*') as name6, 
rpad(name, 10, '*') as name7, ltrim(c8, '*') as c8_1, rtrim(c8, '*') as c8_2, 
trim(c8, '*') as c8_3, regexp_replace(c9,  [...]
+    query = "select ascii(c1) as c1_1, ascii(c2) as c2_1, bit_length(c4) as 
c4_1, length(c4) as c4_2, octet_length(c4) as c4_3, char(c5) as c5_1, 
concat(c1,id,'!') as c1_2, hextoraw(c6) as c6_1, rawtohex(c7) as c7_1, 
insert(name,2,2,'**') as name1, lower(name) as name2, upper(name) as name3, 
left(name, 3) as name4, right(name, 4) as name5, lpad(name, 10, '*') as name6, 
rpad(name, 10, '*') as name7, ltrim(c8, '*') as c8_1, rtrim(c8, '*') as c8_2, 
trim(c8, '*') as c8_3, regexp_replace(c9,  [...]
   }
 }
 
@@ -323,6 +323,20 @@ sink {
           field_value = [
             {equals_to = "Joy Ding"}
           ]
+        },
+        {
+          field_name = "name20"
+          field_type = "string"
+          field_value = [
+            {equals_to = "Joy Ding"}
+          ]
+        },
+        {
+          field_name = "name21"
+          field_type = "string"
+          field_value = [
+            {equals_to = "Joy Ding"}
+          ]
         }
       ]
     }
diff --git 
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLFunction.java
 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLFunction.java
index 10e927516a..76765466a3 100644
--- 
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLFunction.java
+++ 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLFunction.java
@@ -32,6 +32,7 @@ import 
org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
 import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
 import org.apache.seatunnel.transform.exception.TransformException;
 import org.apache.seatunnel.transform.sql.zeta.functions.ArrayFunction;
+import org.apache.seatunnel.transform.sql.zeta.functions.CryptoFunction;
 import org.apache.seatunnel.transform.sql.zeta.functions.DateTimeFunction;
 import org.apache.seatunnel.transform.sql.zeta.functions.MapFunction;
 import org.apache.seatunnel.transform.sql.zeta.functions.NumericFunction;
@@ -129,6 +130,10 @@ public class ZetaSQLFunction {
     public static final String SPLIT = "SPLIT";
     public static final String MURMUR64 = "MURMUR64";
 
+    // -------------------------crypto functions----------------------------
+    public static final String AES_ENCRYPT = "AES_ENCRYPT";
+    public static final String AES_DECRYPT = "AES_DECRYPT";
+
     // -------------------------numeric functions----------------------------
     public static final String ABS = "ABS";
     public static final String ACOS = "ACOS";
@@ -532,6 +537,10 @@ public class ZetaSQLFunction {
                 return StringFunction.split(args);
             case MURMUR64:
                 return StringFunction.murmur64(args);
+            case AES_ENCRYPT:
+                return CryptoFunction.aesEncrypt(args);
+            case AES_DECRYPT:
+                return CryptoFunction.aesDecrypt(args);
             case ABS:
                 return NumericFunction.abs(args);
             case ACOS:
diff --git 
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
index 5fbf4c44d6..8082d3b303 100644
--- 
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
+++ 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/ZetaSQLType.java
@@ -357,6 +357,8 @@ public class ZetaSQLType {
             case ZetaSQLFunction.FROM_UNIXTIME:
             case ZetaSQLFunction.UUID:
             case ZetaSQLFunction.TRIM_SCALE:
+            case ZetaSQLFunction.AES_ENCRYPT:
+            case ZetaSQLFunction.AES_DECRYPT:
                 return BasicType.STRING_TYPE;
             case ZetaSQLFunction.ASCII:
             case ZetaSQLFunction.LOCATE:
diff --git 
a/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunction.java
 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunction.java
new file mode 100644
index 0000000000..10cc926c26
--- /dev/null
+++ 
b/seatunnel-transforms-v2/src/main/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunction.java
@@ -0,0 +1,308 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.transform.sql.zeta.functions;
+
+import org.apache.seatunnel.common.exception.CommonError;
+import org.apache.seatunnel.transform.exception.TransformCommonError;
+import org.apache.seatunnel.transform.sql.zeta.ZetaSQLFunction;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Built-in AES crypto functions for the Zeta SQL transform.
+ *
+ * <p>Both functions use {@code AES/CBC/PKCS5Padding} and return / accept 
Base64-encoded strings.
+ * The wire format when no explicit IV is supplied is {@code Base64(IV || 
ciphertext)} (a random
+ * 16-byte IV is generated per call and prepended), so {@link 
#aesDecrypt(List)} can recover it
+ * without an explicit IV. When an explicit 16-byte IV is supplied, the 
ciphertext carries only the
+ * encrypted bytes and the caller is responsible for the IV.
+ *
+ * <p>Key conventions:
+ *
+ * <ul>
+ *   <li>a key prefixed with {@code base64:} is decoded as a raw AES key 
(16/24/32 bytes for
+ *       AES-128/192/256); only this form is wire-compatible with the {@code 
AesCbcEncryptor} of the
+ *       {@code FieldEncryptTransform};
+ *   <li>any other key is treated as a passphrase and derived via a single 
unsalted SHA-256 whose
+ *       first 16 bytes are used as an AES-128 key. This is fast to 
brute-force for low-entropy
+ *       passphrases; use a random {@code base64:} key for strong protection.
+ * </ul>
+ *
+ * <p>CBC is unauthenticated: a wrong key or corrupted ciphertext may (about 
once in 256) decrypt to
+ * garbage instead of throwing.
+ *
+ * <p>Error paths never embed the key, plaintext, ciphertext or IV into the 
exception message; only
+ * non-sensitive metadata (argument count, byte length, type name) is 
included, following the {@link
+ * CommonError#illegalArgument(String, String)} convention used across the 
codebase.
+ */
+public class CryptoFunction {
+
+    private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
+    private static final String KEY_ALGORITHM = "AES";
+    private static final int IV_SIZE = 16;
+    private static final String BASE64_PREFIX = "base64:";
+
+    // Bounded cache for derived keys, keyed by the key string. A constant key 
(the common case)
+    // is derived once instead of on every row. Cache hits are lock-free 
(ConcurrentHashMap.get);
+    // only a miss derives and inserts, and eviction removes a single entry 
when the cap is exceeded
+    // rather than clearing the whole map, so residency stays at most roughly 
KEY_CACHE_MAX_SIZE.
+    private static final int KEY_CACHE_MAX_SIZE = 64;
+    private static final ConcurrentHashMap<String, SecretKeySpec> KEY_CACHE =
+            new ConcurrentHashMap<>(KEY_CACHE_MAX_SIZE);
+
+    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+    private CryptoFunction() {}
+
+    /**
+     * Encrypts a value with AES/CBC/PKCS5Padding and returns a Base64 string.
+     *
+     * @param args [value, key[, iv]]
+     * @return Base64-encoded ciphertext, or null if the value is null
+     */
+    public static String aesEncrypt(List<Object> args) {
+        String operation = ZetaSQLFunction.AES_ENCRYPT;
+        if (args.size() < 2 || args.size() > 3) {
+            throw CommonError.illegalArgument(
+                    String.valueOf(args.size()), operation + " expects 2 or 3 
arguments");
+        }
+        Object value = args.get(0);
+        if (value == null) {
+            return null;
+        }
+        rejectNonScalar(value, "value", operation);
+        String plainText = value.toString();
+        Object keyArg = args.get(1);
+        if (keyArg == null) {
+            throw CommonError.illegalArgument("key", operation + ": key must 
not be null");
+        }
+        rejectNonScalar(keyArg, "key", operation);
+        SecretKeySpec keySpec = cachedKey(keyArg.toString(), operation);
+
+        byte[] iv = resolveIv(args, operation, true);
+        boolean ivProvided = args.size() == 3;
+
+        try {
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv));
+            byte[] encrypted = 
cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
+            if (ivProvided) {
+                return Base64.getEncoder().encodeToString(encrypted);
+            }
+            byte[] encryptedWithIv = new byte[IV_SIZE + encrypted.length];
+            System.arraycopy(iv, 0, encryptedWithIv, 0, IV_SIZE);
+            System.arraycopy(encrypted, 0, encryptedWithIv, IV_SIZE, 
encrypted.length);
+            return Base64.getEncoder().encodeToString(encryptedWithIv);
+        } catch (Exception e) {
+            throw TransformCommonError.encryptionError(operation + ": 
encryption failed", e);
+        }
+    }
+
+    /**
+     * Decrypts a Base64 AES/CBC/PKCS5Padding ciphertext.
+     *
+     * @param args [ciphertext, key[, iv]]
+     * @return decrypted UTF-8 plaintext, or null if the value is null
+     */
+    public static String aesDecrypt(List<Object> args) {
+        String operation = ZetaSQLFunction.AES_DECRYPT;
+        if (args.size() < 2 || args.size() > 3) {
+            throw CommonError.illegalArgument(
+                    String.valueOf(args.size()), operation + " expects 2 or 3 
arguments");
+        }
+        Object value = args.get(0);
+        if (value == null) {
+            return null;
+        }
+        rejectNonScalar(value, "value", operation);
+        String cipherText = value.toString();
+        Object keyArg = args.get(1);
+        if (keyArg == null) {
+            throw CommonError.illegalArgument("key", operation + ": key must 
not be null");
+        }
+        rejectNonScalar(keyArg, "key", operation);
+        SecretKeySpec keySpec = cachedKey(keyArg.toString(), operation);
+
+        byte[] decoded;
+        try {
+            decoded = Base64.getDecoder().decode(cipherText);
+        } catch (IllegalArgumentException e) {
+            throw CommonError.illegalArgument("value", operation + ": value is 
not valid Base64");
+        }
+
+        boolean ivProvided = args.size() == 3;
+        byte[] iv;
+        byte[] encrypted;
+        if (ivProvided) {
+            iv = resolveIv(args, operation, false);
+            encrypted = decoded;
+            if (encrypted.length == 0) {
+                throw CommonError.illegalArgument(
+                        String.valueOf(decoded.length), operation + ": 
ciphertext is empty");
+            }
+        } else {
+            if (decoded.length < IV_SIZE) {
+                throw CommonError.illegalArgument(
+                        String.valueOf(decoded.length),
+                        operation + ": ciphertext too short to carry a 16-byte 
IV");
+            }
+            iv = new byte[IV_SIZE];
+            encrypted = new byte[decoded.length - IV_SIZE];
+            System.arraycopy(decoded, 0, iv, 0, IV_SIZE);
+            System.arraycopy(decoded, IV_SIZE, encrypted, 0, encrypted.length);
+        }
+
+        try {
+            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv));
+            byte[] original = cipher.doFinal(encrypted);
+            return new String(original, StandardCharsets.UTF_8);
+        } catch (Exception e) {
+            throw TransformCommonError.encryptionError(
+                    operation
+                            + ": decryption failed (wrong key, wrong IV, or 
corrupted ciphertext)",
+                    e);
+        }
+    }
+
+    /**
+     * Resolves the IV. When the IV argument is present it must be non-null 
and 16 bytes; an
+     * explicit null IV is rejected so a column of mixed null/non-null IVs 
cannot silently produce
+     * two different ciphertext layouts. When no IV is provided, a random 
16-byte IV is generated.
+     */
+    private static byte[] resolveIv(List<Object> args, String operation, 
boolean generateIfAbsent) {
+        if (args.size() != 3) {
+            if (!generateIfAbsent) {
+                throw new IllegalStateException("resolveIv called without an 
IV argument");
+            }
+            byte[] iv = new byte[IV_SIZE];
+            SECURE_RANDOM.nextBytes(iv);
+            return iv;
+        }
+        Object ivArg = args.get(2);
+        if (ivArg == null) {
+            throw CommonError.illegalArgument(
+                    "iv",
+                    operation + ": iv must not be null (omit the argument to 
use a random IV)");
+        }
+        rejectNonScalar(ivArg, "iv", operation);
+        return buildIv(ivArg.toString(), operation);
+    }
+
+    /**
+     * Builds an AES key from either a raw Base64 key or a passphrase. See the 
class Javadoc for the
+     * key conventions.
+     */
+    private static SecretKeySpec buildKey(String key, String operation) {
+        if (key == null || key.trim().isEmpty()) {
+            throw CommonError.illegalArgument("key", operation + ": key must 
not be null or blank");
+        }
+        byte[] keyBytes;
+        if (key.startsWith(BASE64_PREFIX)) {
+            String base64 = key.substring(BASE64_PREFIX.length()).trim();
+            try {
+                keyBytes = Base64.getDecoder().decode(base64);
+            } catch (IllegalArgumentException e) {
+                throw CommonError.illegalArgument(
+                        "key", operation + ": base64: key is not valid 
Base64");
+            }
+            if (!(keyBytes.length == 16 || keyBytes.length == 24 || 
keyBytes.length == 32)) {
+                throw CommonError.illegalArgument(
+                        String.valueOf(keyBytes.length),
+                        operation + ": base64: key must be 16, 24 or 32 
bytes");
+            }
+        } else {
+            keyBytes = derivePassphraseKey(key, operation);
+        }
+        return new SecretKeySpec(keyBytes, KEY_ALGORITHM);
+    }
+
+    private static byte[] derivePassphraseKey(String passphrase, String 
operation) {
+        try {
+            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+            byte[] digest = 
sha256.digest(passphrase.getBytes(StandardCharsets.UTF_8));
+            byte[] keyBytes = new byte[16];
+            System.arraycopy(digest, 0, keyBytes, 0, 16);
+            return keyBytes;
+        } catch (NoSuchAlgorithmException e) {
+            // SHA-256 is mandated by every JDK; if it is missing the runtime 
is unusable.
+            throw TransformCommonError.encryptionError(operation, e);
+        }
+    }
+
+    private static byte[] buildIv(String iv, String operation) {
+        byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8);
+        if (ivBytes.length != IV_SIZE) {
+            throw CommonError.illegalArgument(
+                    String.valueOf(ivBytes.length), operation + ": iv must be 
16 bytes");
+        }
+        return ivBytes;
+    }
+
+    /** Rejects array / {@code byte[]} / map inputs that would otherwise be 
encrypted as garbage. */
+    private static void rejectNonScalar(Object value, String argName, String 
operation) {
+        if (value.getClass().isArray() || value instanceof Map) {
+            throw CommonError.illegalArgument(
+                    value.getClass().getName(),
+                    operation
+                            + ": unsupported input type, "
+                            + argName
+                            + " must be a scalar string");
+        }
+    }
+
+    private static SecretKeySpec cachedKey(String key, String operation) {
+        // Lock-free fast path on a hit; on a miss, putIfAbsent atomically 
inserts (per-key, not a
+        // global lock). A concurrent double-derivation is harmless since 
buildKey is deterministic.
+        SecretKeySpec cached = KEY_CACHE.get(key);
+        if (cached != null) {
+            return cached;
+        }
+        SecretKeySpec derived = buildKey(key, operation);
+        SecretKeySpec prior = KEY_CACHE.putIfAbsent(key, derived);
+        if (prior != null) {
+            return prior;
+        }
+        if (KEY_CACHE.size() > KEY_CACHE_MAX_SIZE) {
+            evictOne();
+        }
+        return derived;
+    }
+
+    private static void evictOne() {
+        // Weakly-consistent iterator; removing one entry keeps residency 
approximately bounded
+        // without a global lock and without strict LRU ordering.
+        Iterator<String> it = KEY_CACHE.keySet().iterator();
+        if (it.hasNext()) {
+            it.next();
+            it.remove();
+        }
+    }
+}
diff --git 
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLCryptoFunctionsTest.java
 
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLCryptoFunctionsTest.java
new file mode 100644
index 0000000000..83377381cb
--- /dev/null
+++ 
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/SQLCryptoFunctionsTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.transform.sql;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+public class SQLCryptoFunctionsTest {
+
+    private SeaTunnelRow runSql(String query, SeaTunnelRowType rowType, 
Object... values) {
+        CatalogTable table = CatalogTableUtil.getCatalogTable("test", rowType);
+        ReadonlyConfig config = 
ReadonlyConfig.fromMap(Collections.singletonMap("query", query));
+        SQLTransform transform = new SQLTransform(config, table);
+        List<SeaTunnelRow> out = transform.transformRow(new 
SeaTunnelRow(values));
+        Assertions.assertNotNull(out);
+        Assertions.assertFalse(out.isEmpty());
+        return out.get(0);
+    }
+
+    @Test
+    public void testAesRoundTripWithPassphrase() {
+        // End-to-end: exercises ZetaSQLFunction dispatch + ZetaSQLType 
inference, not just the
+        // static CryptoFunction methods.
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow outRow =
+                runSql(
+                        "select AES_DECRYPT(AES_ENCRYPT(name, 'mySecretPass'), 
'mySecretPass') as r"
+                                + " from dual",
+                        rowType,
+                        "Hello SeaTunnel");
+
+        Assertions.assertInstanceOf(String.class, outRow.getField(0));
+        Assertions.assertEquals("Hello SeaTunnel", outRow.getField(0));
+    }
+
+    @Test
+    public void testAesRoundTripWithExplicitIv() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow outRow =
+                runSql(
+                        "select AES_DECRYPT("
+                                + "AES_ENCRYPT(name, 'mySecretPass', 
'1234567890123456'),"
+                                + " 'mySecretPass', '1234567890123456') as r 
from dual",
+                        rowType,
+                        "Hello SeaTunnel");
+
+        Assertions.assertEquals("Hello SeaTunnel", outRow.getField(0));
+    }
+
+    @Test
+    public void testAesEncryptReturnsBase64String() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow outRow =
+                runSql("select AES_ENCRYPT(name, 'mySecretPass') as c from 
dual", rowType, "plain");
+
+        // Result type must be STRING (verifies ZetaSQLType.getFunctionType 
registration).
+        Assertions.assertInstanceOf(String.class, outRow.getField(0));
+        // A random IV makes the ciphertext non-deterministic, but it must 
differ from the input
+        // and be non-empty.
+        Assertions.assertNotNull(outRow.getField(0));
+        Assertions.assertNotEquals("plain", outRow.getField(0));
+    }
+
+    @Test
+    public void testAesExplicitIvIsDeterministic() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow out1 =
+                runSql(
+                        "select AES_ENCRYPT(name, 'mySecretPass', 
'1234567890123456') as c"
+                                + " from dual",
+                        rowType,
+                        "deterministic");
+        SeaTunnelRow out2 =
+                runSql(
+                        "select AES_ENCRYPT(name, 'mySecretPass', 
'1234567890123456') as c"
+                                + " from dual",
+                        rowType,
+                        "deterministic");
+
+        Assertions.assertEquals(out1.getField(0), out2.getField(0));
+    }
+
+    @Test
+    public void testAesEncryptNullReturnsNull() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow outRow =
+                runSql(
+                        "select AES_ENCRYPT(name, 'mySecretPass') as c from 
dual",
+                        rowType,
+                        (Object) null);
+
+        Assertions.assertNull(outRow.getField(0));
+    }
+
+    @Test
+    public void testAesDecryptNullReturnsNull() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"name"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+        SeaTunnelRow outRow =
+                runSql(
+                        "select AES_DECRYPT(name, 'mySecretPass') as r from 
dual",
+                        rowType,
+                        (Object) null);
+
+        Assertions.assertNull(outRow.getField(0));
+    }
+}
diff --git 
a/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunctionTest.java
 
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunctionTest.java
new file mode 100644
index 0000000000..b15270dbf7
--- /dev/null
+++ 
b/seatunnel-transforms-v2/src/test/java/org/apache/seatunnel/transform/sql/zeta/functions/CryptoFunctionTest.java
@@ -0,0 +1,479 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.transform.sql.zeta.functions;
+
+import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+import org.apache.seatunnel.transform.encrypt.encryptor.AesCbcEncryptor;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class CryptoFunctionTest {
+
+    private static final String PASSPHRASE = "mySecretPass";
+    private static final String EXPLICIT_IV = "1234567890123456"; // 16 bytes
+    private static final String PLAINTEXT = "Hello SeaTunnel";
+
+    private List<Object> args(Object... values) {
+        List<Object> list = new ArrayList<>();
+        for (Object v : values) {
+            list.add(v);
+        }
+        return list;
+    }
+
+    @Test
+    public void testRoundTripWithPassphraseAndRandomIv() {
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE));
+        Assertions.assertNotNull(cipher);
+        Assertions.assertNotEquals(PLAINTEXT, cipher);
+        String decrypted = CryptoFunction.aesDecrypt(args(cipher, PASSPHRASE));
+        Assertions.assertEquals(PLAINTEXT, decrypted);
+    }
+
+    @Test
+    public void testRoundTripWithPassphraseAndExplicitIv() {
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        Assertions.assertNotNull(cipher);
+        // With an explicit IV the ciphertext does not carry the IV prefix.
+        String decrypted = CryptoFunction.aesDecrypt(args(cipher, PASSPHRASE, 
EXPLICIT_IV));
+        Assertions.assertEquals(PLAINTEXT, decrypted);
+    }
+
+    @Test
+    public void testRoundTripWithBase64KeyAndRandomIv() {
+        byte[] rawKey = new byte[16];
+        for (int i = 0; i < rawKey.length; i++) {
+            rawKey[i] = (byte) i;
+        }
+        String base64Key = "base64:" + 
Base64.getEncoder().encodeToString(rawKey);
+
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, base64Key));
+        Assertions.assertNotNull(cipher);
+        String decrypted = CryptoFunction.aesDecrypt(args(cipher, base64Key));
+        Assertions.assertEquals(PLAINTEXT, decrypted);
+    }
+
+    @Test
+    public void testRoundTripWithBase64KeyAndExplicitIv() {
+        byte[] rawKey = new byte[32];
+        for (int i = 0; i < rawKey.length; i++) {
+            rawKey[i] = (byte) (i + 1);
+        }
+        String base64Key = "base64:" + 
Base64.getEncoder().encodeToString(rawKey);
+
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, base64Key, 
EXPLICIT_IV));
+        String decrypted = CryptoFunction.aesDecrypt(args(cipher, base64Key, 
EXPLICIT_IV));
+        Assertions.assertEquals(PLAINTEXT, decrypted);
+    }
+
+    @Test
+    public void testRandomIvProducesDifferentCiphertext() {
+        String cipher1 = CryptoFunction.aesEncrypt(args(PLAINTEXT, 
PASSPHRASE));
+        String cipher2 = CryptoFunction.aesEncrypt(args(PLAINTEXT, 
PASSPHRASE));
+        // A random IV makes the ciphertext non-deterministic.
+        Assertions.assertNotEquals(cipher1, cipher2);
+        Assertions.assertEquals(PLAINTEXT, 
CryptoFunction.aesDecrypt(args(cipher1, PASSPHRASE)));
+        Assertions.assertEquals(PLAINTEXT, 
CryptoFunction.aesDecrypt(args(cipher2, PASSPHRASE)));
+    }
+
+    @Test
+    public void testExplicitIvIsDeterministic() {
+        String cipher1 = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        String cipher2 = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        Assertions.assertEquals(cipher1, cipher2);
+    }
+
+    @Test
+    public void testEncryptNullValueReturnsNull() {
+        Assertions.assertNull(CryptoFunction.aesEncrypt(args(null, 
PASSPHRASE)));
+    }
+
+    @Test
+    public void testDecryptNullValueReturnsNull() {
+        Assertions.assertNull(CryptoFunction.aesDecrypt(args(null, 
PASSPHRASE)));
+    }
+
+    @Test
+    public void testEncryptTooFewArgsThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class, () -> 
CryptoFunction.aesEncrypt(args(PLAINTEXT)));
+    }
+
+    @Test
+    public void testEncryptTooManyArgsThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV, "extra")));
+    }
+
+    @Test
+    public void testDecryptTooFewArgsThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args("ciphertext")));
+    }
+
+    @Test
+    public void testDecryptTooManyArgsThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () ->
+                        CryptoFunction.aesDecrypt(
+                                args("ciphertext", PASSPHRASE, EXPLICIT_IV, 
"extra")));
+    }
+
+    @Test
+    public void testEncryptWithNullKeyThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, null)));
+    }
+
+    @Test
+    public void testDecryptWithNullKeyThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args("ciphertext", null)));
+    }
+
+    @Test
+    public void testEncryptWithEmptyKeyThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, "")));
+    }
+
+    @Test
+    public void testEncryptWithBlankKeyThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, "   ")));
+    }
+
+    @Test
+    public void testBase64KeyMalformedThrows() {
+        // The base64: prefix is present but the remainder is not valid Base64 
content.
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, 
"base64:@@@@")));
+    }
+
+    @Test
+    public void testDecryptWithWrongKeyDoesNotLeakPlaintext() {
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE));
+        // CBC/PKCS5Padding decryption with a wrong key usually fails the 
padding check, but a
+        // random block can pass it (~1/256). Both outcomes are acceptable: 
throwing, or returning
+        // garbage, as long as the real plaintext is never recovered and no 
secret is leaked.
+        try {
+            String result = CryptoFunction.aesDecrypt(args(cipher, 
"wrongPassphrase"));
+            Assertions.assertNotEquals(PLAINTEXT, result);
+        } catch (SeaTunnelRuntimeException e) {
+            assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT, cipher);
+        }
+    }
+
+    @Test
+    public void testExplicitIvWrongLengthThrows() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
"short")));
+    }
+
+    @Test
+    public void testBase64KeyWrongLengthThrows() {
+        byte[] rawKey = new byte[10]; // invalid AES key length
+        String base64Key = "base64:" + 
Base64.getEncoder().encodeToString(rawKey);
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, base64Key)));
+    }
+
+    @Test
+    public void testDecryptInvalidBase64Throws() {
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args("@@invalid@@", 
PASSPHRASE)));
+    }
+
+    @Test
+    public void testDecryptTooShortPayloadThrows() {
+        // A valid Base64 string that decodes to fewer than 16 bytes cannot 
carry the IV.
+        String shortBase64 = Base64.getEncoder().encodeToString(new byte[8]);
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args(shortBase64, 
PASSPHRASE)));
+    }
+
+    @Test
+    public void testRoundTripUnicodePlaintext() {
+        String unicode = "加密测试🔐数据";
+        String cipher = CryptoFunction.aesEncrypt(args(unicode, PASSPHRASE));
+        Assertions.assertEquals(unicode, 
CryptoFunction.aesDecrypt(args(cipher, PASSPHRASE)));
+    }
+
+    @Test
+    public void testExplicitIvCiphertextCannotBeDecryptedWithoutIv() {
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        // The explicit-IV ciphertext carries no IV prefix, so decrypting 
without an IV interprets
+        // the first 16 ciphertext bytes as the IV and decrypts the rest with 
it. This must not
+        // return the original plaintext (it throws on a padding failure, or 
yields garbage).
+        try {
+            String result = CryptoFunction.aesDecrypt(args(cipher, 
PASSPHRASE));
+            Assertions.assertNotEquals(PLAINTEXT, result);
+        } catch (SeaTunnelRuntimeException e) {
+            assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT, cipher);
+        }
+    }
+
+    @Test
+    public void testRandomIvCiphertextRoundTripsWithoutExplicitIv() {
+        // Ciphertext produced without an IV embeds the IV in its first 16 
bytes; decrypting
+        // without an IV must recover the original plaintext.
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE));
+        Assertions.assertEquals(PLAINTEXT, 
CryptoFunction.aesDecrypt(args(cipher, PASSPHRASE)));
+    }
+
+    @Test
+    public void testExplicitIvEncryptionIsDeterministic() {
+        // Two encryptions with the same passphrase and explicit IV must 
produce the same
+        // ciphertext, proving the SHA-256 key derivation is deterministic.
+        String cipher1 = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        String cipher2 = CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
EXPLICIT_IV));
+        Assertions.assertEquals(cipher1, cipher2);
+    }
+
+    @Test
+    public void testRoundTripWithAes192Base64Key() {
+        // Covers the 24-byte (AES-192) branch of the raw key length 
validation.
+        byte[] rawKey = new byte[24];
+        for (int i = 0; i < rawKey.length; i++) {
+            rawKey[i] = (byte) (i + 5);
+        }
+        String base64Key = "base64:" + 
Base64.getEncoder().encodeToString(rawKey);
+        String cipher = CryptoFunction.aesEncrypt(args(PLAINTEXT, base64Key, 
EXPLICIT_IV));
+        Assertions.assertEquals(
+                PLAINTEXT, CryptoFunction.aesDecrypt(args(cipher, base64Key, 
EXPLICIT_IV)));
+    }
+
+    @Test
+    public void testDecryptExplicitIvEmptyPayloadThrows() {
+        // An empty ciphertext combined with an explicit IV has nothing to 
decrypt.
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args("", PASSPHRASE, 
EXPLICIT_IV)));
+    }
+
+    @Test
+    public void testExplicitNullIvThrows() {
+        // An explicit null IV is rejected (consistent with a null key) so a 
column of mixed
+        // null/non-null IVs cannot silently produce two different ciphertext 
layouts. Omit the
+        // argument entirely to use a random IV.
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, PASSPHRASE, 
null)));
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesDecrypt(args("ciphertext", PASSPHRASE, 
null)));
+    }
+
+    @Test
+    public void testErrorMessageDoesNotContainSecrets() {
+        // None of the error paths may echo the key, the plaintext, the 
ciphertext or the arg list
+        // into the exception message (which surfaces in engine logs).
+        String secretKey = "superSecretKeyValue";
+        String sensitive = "sensitiveDataValue";
+
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () ->
+                                CryptoFunction.aesEncrypt(
+                                        args(sensitive, secretKey, 
EXPLICIT_IV, "extra")));
+        assertMessageHasNoSecrets(e, secretKey, sensitive, EXPLICIT_IV);
+
+        e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(sensitive, 
null)));
+        assertMessageHasNoSecrets(e, secretKey, sensitive);
+
+        e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(sensitive, "")));
+        assertMessageHasNoSecrets(e, secretKey, sensitive);
+
+        String badBase64Key = "base64:" + 
Base64.getEncoder().encodeToString(new byte[10]);
+        e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(sensitive, 
badBase64Key)));
+        assertMessageHasNoSecrets(e, secretKey, sensitive, badBase64Key);
+
+        e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesDecrypt(args("@@invalid@@", 
secretKey)));
+        assertMessageHasNoSecrets(e, secretKey, sensitive);
+    }
+
+    @Test
+    public void testEncryptByteArrayInputThrows() {
+        // A byte[] column would otherwise be encrypted as "[B@...", which can 
never be recovered.
+        byte[] bytes = PLAINTEXT.getBytes(StandardCharsets.UTF_8);
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(bytes, 
PASSPHRASE)));
+        assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT);
+    }
+
+    @Test
+    public void testEncryptArrayAndMapInputThrows() {
+        String[] array = new String[] {"a", "b"};
+        Map<String, String> map = new HashMap<>();
+        map.put("k", "v");
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(array, PASSPHRASE)));
+        Assertions.assertThrows(
+                SeaTunnelRuntimeException.class,
+                () -> CryptoFunction.aesEncrypt(args(map, PASSPHRASE)));
+    }
+
+    @Test
+    public void testEncryptByteArrayKeyThrows() {
+        // A byte[] key would be stringified as "[B@..." and silently derive a 
different AES key
+        // per row (each byte[] is a distinct object), so it must be rejected 
like a byte[] value.
+        byte[] bytes = PLAINTEXT.getBytes(StandardCharsets.UTF_8);
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, 
bytes)));
+        assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT);
+    }
+
+    @Test
+    public void testDecryptByteArrayKeyThrows() {
+        byte[] bytes = PASSPHRASE.getBytes(StandardCharsets.UTF_8);
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesDecrypt(args("ciphertext", 
bytes)));
+        assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT);
+    }
+
+    @Test
+    public void testEncryptByteArrayIvThrows() {
+        byte[] bytes = new byte[16];
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesEncrypt(args(PLAINTEXT, 
PASSPHRASE, bytes)));
+        assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT);
+        // Pins the iv guard in resolveIv: without it a byte[] iv would be 
stringified to "[B@..."
+        // and rejected only for the wrong length, with a message that omits 
this phrase.
+        Assertions.assertTrue(
+                e.getMessage().contains("unsupported input type"),
+                "byte[] iv must be rejected by the non-scalar guard");
+    }
+
+    @Test
+    public void testDecryptByteArrayIvThrows() {
+        // The value must be valid Base64 so the decode succeeds and execution 
reaches the IV check.
+        String validBase64 = Base64.getEncoder().encodeToString(new byte[32]);
+        byte[] bytes = new byte[16];
+        SeaTunnelRuntimeException e =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () -> CryptoFunction.aesDecrypt(args(validBase64, 
PASSPHRASE, bytes)));
+        assertMessageHasNoSecrets(e, PASSPHRASE, PLAINTEXT);
+        // Pins the iv guard in resolveIv: without it a byte[] iv would be 
stringified to "[B@..."
+        // and rejected only for the wrong length, with a message that omits 
this phrase.
+        Assertions.assertTrue(
+                e.getMessage().contains("unsupported input type"),
+                "byte[] iv must be rejected by the non-scalar guard");
+    }
+
+    @Test
+    public void testInteropWithAesCbcEncryptor() {
+        // The base64: key form must be wire-compatible with FieldEncrypt's 
AesCbcEncryptor:
+        // same algorithm, same Base64(IV || ciphertext) layout.
+        byte[] rawKey = new byte[16];
+        for (int i = 0; i < rawKey.length; i++) {
+            rawKey[i] = (byte) (i + 1);
+        }
+        String base64Key = "base64:" + 
Base64.getEncoder().encodeToString(rawKey);
+        String plain = "interop data";
+
+        AesCbcEncryptor encryptor = new AesCbcEncryptor();
+        encryptor.init(base64Key);
+
+        // AesCbcEncryptor encrypts, CryptoFunction decrypts.
+        String cipher = encryptor.encrypt(plain);
+        Assertions.assertEquals(plain, CryptoFunction.aesDecrypt(args(cipher, 
base64Key)));
+
+        // CryptoFunction encrypts, AesCbcEncryptor decrypts.
+        String cipher2 = CryptoFunction.aesEncrypt(args(plain, base64Key));
+        Assertions.assertEquals(plain, encryptor.decrypt(cipher2));
+    }
+
+    @Test
+    public void testPassphraseKeyDerivationKnownAnswer() throws Exception {
+        // Known-answer: independently derive the passphrase key (SHA-256, 
first 16 bytes) and the
+        // ciphertext with raw javax.crypto, then assert CryptoFunction 
produces the exact same
+        // output. This pins both the key derivation and the explicit-IV wire 
format.
+        MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
+        byte[] digest = 
sha256.digest(PASSPHRASE.getBytes(StandardCharsets.UTF_8));
+        byte[] keyBytes = Arrays.copyOf(digest, 16);
+
+        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+        cipher.init(
+                Cipher.ENCRYPT_MODE,
+                new SecretKeySpec(keyBytes, "AES"),
+                new 
IvParameterSpec(EXPLICIT_IV.getBytes(StandardCharsets.UTF_8)));
+        byte[] encrypted = 
cipher.doFinal(PLAINTEXT.getBytes(StandardCharsets.UTF_8));
+        String expected = Base64.getEncoder().encodeToString(encrypted);
+
+        Assertions.assertEquals(
+                expected, CryptoFunction.aesEncrypt(args(PLAINTEXT, 
PASSPHRASE, EXPLICIT_IV)));
+    }
+
+    private static void assertMessageHasNoSecrets(SeaTunnelRuntimeException e, 
String... secrets) {
+        String message = e.getMessage();
+        for (String secret : secrets) {
+            Assertions.assertFalse(
+                    message.contains(secret),
+                    "Exception message must not contain secret material: " + 
secret);
+        }
+    }
+}

Reply via email to