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

singhpk234 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 0f3131f02f Aliyun: Add AliyunKeyManagementClient for client-side 
encryption (#18210)
0f3131f02f is described below

commit 0f3131f02fc2560bd122f3b323d13ea935892eb3
Author: mengna-lin <[email protected]>
AuthorDate: Fri Oct 9 17:21:13 2026 -0700

    Aliyun: Add AliyunKeyManagementClient for client-side encryption (#18210)
    
    * Aliyun: Add AliyunKeyManagementClient for client-side encryption
    
    Add an Alibaba Cloud KMS implementation of the KeyManagementClient SPI so
    tables using Iceberg envelope encryption can wrap/unwrap data keys with
    Aliyun KMS, at parity with AwsKeyManagementClient. The aliyun module
    previously supported only OSS.
    
    - AliyunKeyManagementClient: wrapKey, unwrapKey, and server-side
      generateKey; supportsKeyGeneration() returns true.
    - AliyunClientFactory#newKmsClient: build the KMS client from
      client.region, resolving credentials from access key/secret/token and
      falling back to the default chain (env vars, RRSA/OIDC, ECS RAM role).
    - AliyunProperties: add client.region and kms.data-key-spec (default 
AES_256).
---
 .../iceberg/aliyun/AliyunClientFactories.java      |  65 +++++-
 .../apache/iceberg/aliyun/AliyunClientFactory.java |  11 +
 .../iceberg/aliyun/AliyunKeyManagementClient.java  | 149 +++++++++++++
 .../apache/iceberg/aliyun/AliyunProperties.java    |  64 ++++++
 .../iceberg/aliyun/TestAliyunClientFactories.java  |  28 +++
 .../aliyun/TestAliyunKeyManagementClient.java      | 231 +++++++++++++++++++++
 build.gradle                                       |   3 +
 .../java/org/apache/iceberg/CatalogProperties.java |   3 +
 .../apache/iceberg/encryption/EncryptionUtil.java  |   2 +
 docs/docs/encryption.md                            |   2 +-
 gradle/libs.versions.toml                          |   4 +-
 11 files changed, 558 insertions(+), 4 deletions(-)

diff --git 
a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java 
b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java
index 8bb88db83e..31a34aabf6 100644
--- a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java
+++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java
@@ -20,11 +20,13 @@ package org.apache.iceberg.aliyun;
 
 import com.aliyun.credentials.models.CredentialModel;
 import com.aliyun.credentials.provider.OIDCRoleArnCredentialProvider;
+import com.aliyun.kms20160120.Client;
 import com.aliyun.oss.OSS;
 import com.aliyun.oss.OSSClientBuilder;
 import com.aliyun.oss.common.auth.BasicCredentials;
 import com.aliyun.oss.common.auth.Credentials;
 import com.aliyun.oss.common.auth.CredentialsProvider;
+import com.aliyun.teaopenapi.models.Config;
 import java.util.Map;
 import org.apache.iceberg.common.DynConstructors;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -111,6 +113,11 @@ public class AliyunClientFactories {
           && !Strings.isNullOrEmpty(oidcTokenFile);
     }
 
+    /** Build an RRSA (OIDC role) credential provider with built-in caching 
and auto-refresh. */
+    private OIDCRoleArnCredentialProvider rrsaCredentialProvider() {
+      return OIDCRoleArnCredentialProvider.builder().build();
+    }
+
     @Override
     public OSS newOSSClient() {
       Preconditions.checkNotNull(
@@ -127,8 +134,7 @@ public class AliyunClientFactories {
               endpoint);
 
           // Use OIDCRoleArnCredentialProvider directly with built-in caching 
and auto-refresh
-          final OIDCRoleArnCredentialProvider oidcProvider =
-              OIDCRoleArnCredentialProvider.builder().build();
+          final OIDCRoleArnCredentialProvider oidcProvider = 
rrsaCredentialProvider();
 
           CredentialsProvider ossCredProvider =
               new CredentialsProvider() {
@@ -185,6 +191,61 @@ public class AliyunClientFactories {
       this.aliyunProperties = new AliyunProperties(properties);
     }
 
+    /**
+     * Creates the Aliyun KMS client. Credentials are resolved in the same 
order as {@link
+     * #newOSSClient()}:
+     *
+     * <ol>
+     *   <li>RRSA/OIDC when its environment variables are present; this takes 
precedence over the
+     *       static access-key properties.
+     *   <li>otherwise the static access key / secret (+ optional security 
token).
+     * </ol>
+     */
+    @Override
+    public Client newKmsClient() {
+      Preconditions.checkNotNull(
+          aliyunProperties,
+          "Cannot create aliyun kms client before initializing the 
AliyunClientFactory.");
+      Preconditions.checkArgument(
+          !Strings.isNullOrEmpty(aliyunProperties.region()),
+          "Cannot create aliyun kms client, %s is not set.",
+          AliyunProperties.CLIENT_REGION);
+
+      // the KMS client resolves kms.<region>.aliyuncs.com from the region id
+      Config config = new Config().setRegionId(aliyunProperties.region());
+
+      // Optional endpoint override, e.g. a KMS Instance (DKMS) endpoint.
+      if (!Strings.isNullOrEmpty(aliyunProperties.kmsEndpoint())) {
+        config.setEndpoint(aliyunProperties.kmsEndpoint());
+      }
+
+      // Check if RRSA environment is available (same credential resolution 
order as newOSSClient)
+      if (isRrsaEnvironmentAvailable()) {
+        try {
+          LOG.info(
+              "Detected RRSA environment variables, creating KMS client with 
RRSA credentials");
+          // Use OIDCRoleArnCredentialProvider directly with built-in caching 
and auto-refresh
+          OIDCRoleArnCredentialProvider oidcProvider = 
rrsaCredentialProvider();
+          config.setCredential(new 
com.aliyun.credentials.Client(oidcProvider));
+          return new Client(config);
+        } catch (Exception e) {
+          throw new RuntimeException("Failed to create RRSA KMS client", e);
+        }
+      }
+
+      try {
+        config
+            .setAccessKeyId(aliyunProperties.accessKeyId())
+            .setAccessKeySecret(aliyunProperties.accessKeySecret());
+        if (!Strings.isNullOrEmpty(aliyunProperties.securityToken())) {
+          config.setSecurityToken(aliyunProperties.securityToken());
+        }
+        return new Client(config);
+      } catch (Exception e) {
+        throw new RuntimeException("Failed to create aliyun kms client", e);
+      }
+    }
+
     @Override
     public AliyunProperties aliyunProperties() {
       return aliyunProperties;
diff --git 
a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactory.java 
b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactory.java
index 67490db578..b8c8e49871 100644
--- a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactory.java
+++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactory.java
@@ -18,6 +18,7 @@
  */
 package org.apache.iceberg.aliyun;
 
+import com.aliyun.kms20160120.Client;
 import com.aliyun.oss.OSS;
 import java.io.Serializable;
 import java.util.Map;
@@ -31,6 +32,16 @@ public interface AliyunClientFactory extends Serializable {
    */
   OSS newOSSClient();
 
+  /**
+   * Create an aliyun KMS client.
+   *
+   * @return kms client.
+   */
+  default Client newKmsClient() {
+    throw new UnsupportedOperationException(
+        this.getClass().getName() + " does not support creating a KMS client");
+  }
+
   /**
    * Initialize Aliyun client factory from catalog properties.
    *
diff --git 
a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunKeyManagementClient.java 
b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunKeyManagementClient.java
new file mode 100644
index 0000000000..3bebc63af6
--- /dev/null
+++ 
b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunKeyManagementClient.java
@@ -0,0 +1,149 @@
+/*
+ * 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.iceberg.aliyun;
+
+import com.aliyun.kms20160120.Client;
+import com.aliyun.kms20160120.models.DecryptRequest;
+import com.aliyun.kms20160120.models.DecryptResponse;
+import com.aliyun.kms20160120.models.EncryptRequest;
+import com.aliyun.kms20160120.models.EncryptResponse;
+import com.aliyun.teautil.models.RuntimeOptions;
+import java.nio.ByteBuffer;
+import java.util.Base64;
+import java.util.Map;
+import org.apache.iceberg.encryption.KeyManagementClient;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.ByteBuffers;
+import org.apache.iceberg.util.SerializableMap;
+
+/**
+ * Key management client implementation that uses Alibaba Cloud KMS. Wraps 
(encrypts) and unwraps
+ * (decrypts) data keys with a KMS-managed master key referenced by its key id.
+ */
+public class AliyunKeyManagementClient implements KeyManagementClient {
+
+  private static final int BACKOFF_PERIOD_MS = 100;
+  private static final String ALIAS_PREFIX = "alias/";
+  private static final String ARN_KEY_SEPARATOR = "key/";
+
+  private Map<String, String> allProperties;
+  private AliyunProperties aliyunProperties;
+
+  private transient volatile ClientState state;
+
+  @Override
+  public void initialize(Map<String, String> properties) {
+    this.allProperties = SerializableMap.copyOf(properties);
+    this.aliyunProperties = new AliyunProperties(properties);
+  }
+
+  @Override
+  public ByteBuffer wrapKey(ByteBuffer key, String wrappingKeyId) {
+    EncryptRequest request =
+        new EncryptRequest()
+            .setKeyId(wrappingKeyId)
+            
.setPlaintext(Base64.getEncoder().encodeToString(ByteBuffers.toByteArray(key)));
+    try {
+      EncryptResponse response = client().encryptWithOptions(request, 
runtimeOptions());
+      return base64ToBuffer(response.getBody().getCiphertextBlob());
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to wrap key with Aliyun KMS", e);
+    }
+  }
+
+  @Override
+  public ByteBuffer unwrapKey(ByteBuffer wrappedKey, String wrappingKeyId) {
+    DecryptResponse response;
+    try {
+      DecryptRequest request =
+          new DecryptRequest()
+              .setCiphertextBlob(
+                  
Base64.getEncoder().encodeToString(ByteBuffers.toByteArray(wrappedKey)));
+      response = client().decryptWithOptions(request, runtimeOptions());
+    } catch (Exception e) {
+      throw new RuntimeException("Failed to unwrap key with Aliyun KMS", e);
+    }
+
+    // Verify the key that wrapped the data key. Skip aliases: an alias is a 
mutable pointer whose
+    // target may differ from the key that wrapped older data. Only bare ids 
and ARNs are checked.
+    String actualKeyId = response.getBody().getKeyId();
+    if (!isAlias(wrappingKeyId)) {
+      String expectedKeyId = keyIdFromRef(wrappingKeyId);
+      Preconditions.checkState(
+          expectedKeyId.equals(actualKeyId),
+          "Data key was wrapped by KMS key %s, but unwrap expected key %s",
+          actualKeyId,
+          expectedKeyId);
+    }
+    return base64ToBuffer(response.getBody().getPlaintext());
+  }
+
+  private static ByteBuffer base64ToBuffer(String base64) {
+    return ByteBuffer.wrap(Base64.getDecoder().decode(base64));
+  }
+
+  // bare alias "alias/name" or alias ARN "acs:kms:...:alias/name"
+  private static boolean isAlias(String keyRef) {
+    return keyRef.contains(ALIAS_PREFIX);
+  }
+
+  // key ARN -> id after "key/"; bare id -> unchanged
+  private static String keyIdFromRef(String keyRef) {
+    int idx = keyRef.lastIndexOf(ARN_KEY_SEPARATOR);
+    return idx >= 0 ? keyRef.substring(idx + ARN_KEY_SEPARATOR.length()) : 
keyRef;
+  }
+
+  private Client client() {
+    return state().client;
+  }
+
+  private RuntimeOptions runtimeOptions() {
+    return state().runtimeOptions;
+  }
+
+  private ClientState state() {
+    if (state == null) {
+      synchronized (this) {
+        if (state == null) {
+          Client kmsClient = 
AliyunClientFactories.from(allProperties).newKmsClient();
+          RuntimeOptions options =
+              new RuntimeOptions()
+                  .setAutoretry(true)
+                  .setMaxAttempts(aliyunProperties.kmsClientMaxAttempts())
+                  .setBackoffPolicy("fixed")
+                  .setBackoffPeriod(BACKOFF_PERIOD_MS)
+                  
.setConnectTimeout(aliyunProperties.kmsClientConnectTimeoutMs())
+                  .setReadTimeout(aliyunProperties.kmsClientReadTimeoutMs());
+          state = new ClientState(kmsClient, options);
+        }
+      }
+    }
+    return state;
+  }
+
+  private static class ClientState {
+    private final Client client;
+    private final RuntimeOptions runtimeOptions;
+
+    ClientState(Client client, RuntimeOptions runtimeOptions) {
+      this.client = client;
+      this.runtimeOptions = runtimeOptions;
+    }
+  }
+}
diff --git 
a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunProperties.java 
b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunProperties.java
index 4de784f40c..0faf387fcd 100644
--- a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunProperties.java
+++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunProperties.java
@@ -76,11 +76,43 @@ public class AliyunProperties implements Serializable {
    */
   public static final String OSS_STAGING_DIRECTORY = "oss.staging-dir";
 
+  /**
+   * The region id used to derive a service endpoint, for example {@code 
cn-hangzhou}. Used by the
+   * Aliyun KMS client to resolve {@code kms.<region>.aliyuncs.com}.
+   */
+  public static final String CLIENT_REGION = "client.region";
+
+  /**
+   * Overrides the KMS endpoint. Defaults to the region-derived {@code 
kms.<region>.aliyuncs.com}.
+   * Set to a KMS Instance (DKMS) endpoint for keys that live in a KMS 
Instance.
+   */
+  public static final String CLIENT_KMS_ENDPOINT = "client.kms-endpoint";
+
+  /** Maximum number of attempts (including the first) for each KMS call. */
+  public static final String KMS_CLIENT_MAX_ATTEMPTS = 
"kms.client.aliyun.max.attempts";
+
+  public static final int KMS_CLIENT_MAX_ATTEMPTS_DEFAULT = 3;
+
+  /** Connect timeout in milliseconds for KMS calls. */
+  public static final String KMS_CLIENT_CONNECT_TIMEOUT_MS = 
"kms.client.aliyun.connect.timeout.ms";
+
+  public static final int KMS_CLIENT_CONNECT_TIMEOUT_MS_DEFAULT = 2_000;
+
+  /** Read timeout in milliseconds for KMS calls. */
+  public static final String KMS_CLIENT_READ_TIMEOUT_MS = 
"kms.client.aliyun.read.timeout.ms";
+
+  public static final int KMS_CLIENT_READ_TIMEOUT_MS_DEFAULT = 30_000;
+
   private final String ossEndpoint;
   private final String accessKeyId;
   private final String accessKeySecret;
   private final String securityToken;
   private final String ossStagingDirectory;
+  private final String region;
+  private final String kmsEndpoint;
+  private final int kmsClientMaxAttempts;
+  private final int kmsClientConnectTimeoutMs;
+  private final int kmsClientReadTimeoutMs;
 
   public AliyunProperties() {
     this(ImmutableMap.of());
@@ -96,6 +128,18 @@ public class AliyunProperties implements Serializable {
     this.ossStagingDirectory =
         PropertyUtil.propertyAsString(
             properties, OSS_STAGING_DIRECTORY, 
System.getProperty("java.io.tmpdir"));
+
+    this.region = properties.get(CLIENT_REGION);
+    this.kmsEndpoint = properties.get(CLIENT_KMS_ENDPOINT);
+    this.kmsClientMaxAttempts =
+        PropertyUtil.propertyAsInt(
+            properties, KMS_CLIENT_MAX_ATTEMPTS, 
KMS_CLIENT_MAX_ATTEMPTS_DEFAULT);
+    this.kmsClientConnectTimeoutMs =
+        PropertyUtil.propertyAsInt(
+            properties, KMS_CLIENT_CONNECT_TIMEOUT_MS, 
KMS_CLIENT_CONNECT_TIMEOUT_MS_DEFAULT);
+    this.kmsClientReadTimeoutMs =
+        PropertyUtil.propertyAsInt(
+            properties, KMS_CLIENT_READ_TIMEOUT_MS, 
KMS_CLIENT_READ_TIMEOUT_MS_DEFAULT);
   }
 
   public String ossEndpoint() {
@@ -117,4 +161,24 @@ public class AliyunProperties implements Serializable {
   public String ossStagingDirectory() {
     return ossStagingDirectory;
   }
+
+  public String region() {
+    return region;
+  }
+
+  public String kmsEndpoint() {
+    return kmsEndpoint;
+  }
+
+  public int kmsClientMaxAttempts() {
+    return kmsClientMaxAttempts;
+  }
+
+  public int kmsClientConnectTimeoutMs() {
+    return kmsClientConnectTimeoutMs;
+  }
+
+  public int kmsClientReadTimeoutMs() {
+    return kmsClientReadTimeoutMs;
+  }
 }
diff --git 
a/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java 
b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java
index 3fd4cfe87a..f0d1cd36bc 100644
--- 
a/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java
+++ 
b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java
@@ -19,7 +19,9 @@
 package org.apache.iceberg.aliyun;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
+import com.aliyun.kms20160120.Client;
 import com.aliyun.oss.OSS;
 import java.util.Map;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
@@ -135,6 +137,27 @@ public class TestAliyunClientFactories {
         .isFalse();
   }
 
+  @Test
+  public void testNewKmsClientResolvesEndpointFromRegion() {
+    Map<String, String> properties = Maps.newHashMap();
+    properties.put(AliyunProperties.CLIENT_REGION, "cn-hangzhou");
+    properties.put(AliyunProperties.CLIENT_ACCESS_KEY_ID, "key");
+    properties.put(AliyunProperties.CLIENT_ACCESS_KEY_SECRET, "secret");
+
+    Client kmsClient = AliyunClientFactories.from(properties).newKmsClient();
+    assertThat(kmsClient)
+        .as("KMS client should be created with an endpoint resolved from the 
region")
+        .isNotNull();
+  }
+
+  @Test
+  public void testNewKmsClientRequiresRegion() {
+    AliyunClientFactory factory = 
AliyunClientFactories.from(Maps.newHashMap());
+    assertThatThrownBy(factory::newKmsClient)
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining(AliyunProperties.CLIENT_REGION);
+  }
+
   public static class CustomFactory implements AliyunClientFactory {
 
     AliyunProperties aliyunProperties;
@@ -146,6 +169,11 @@ public class TestAliyunClientFactories {
       return null;
     }
 
+    @Override
+    public Client newKmsClient() {
+      return null;
+    }
+
     @Override
     public void initialize(Map<String, String> properties) {
       this.aliyunProperties = new AliyunProperties(properties);
diff --git 
a/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunKeyManagementClient.java
 
b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunKeyManagementClient.java
new file mode 100644
index 0000000000..ba352b5f36
--- /dev/null
+++ 
b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunKeyManagementClient.java
@@ -0,0 +1,231 @@
+/*
+ * 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.iceberg.aliyun;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.aliyun.kms20160120.Client;
+import com.aliyun.kms20160120.models.DecryptRequest;
+import com.aliyun.kms20160120.models.DecryptResponse;
+import com.aliyun.kms20160120.models.DecryptResponseBody;
+import com.aliyun.kms20160120.models.EncryptRequest;
+import com.aliyun.kms20160120.models.EncryptResponse;
+import com.aliyun.kms20160120.models.EncryptResponseBody;
+import com.aliyun.oss.OSS;
+import com.aliyun.teautil.models.RuntimeOptions;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Map;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.TestHelpers;
+import org.apache.iceberg.encryption.EncryptionUtil;
+import org.apache.iceberg.encryption.KeyManagementClient;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+
+public class TestAliyunKeyManagementClient {
+
+  private static final String WRAPPING_KEY_ID = "test-wrapping-key";
+  private static final String SAMPLE_KEY_ID = 
"202b9877-5a25-46e3-a763-e20791b5abcd";
+  private static final byte[] RAW_KEY =
+      "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8);
+  private static final String RAW_KEY_B64 = 
Base64.getEncoder().encodeToString(RAW_KEY);
+  // Aliyun KMS returns the ciphertext blob as a Base64 string; the wrapped 
key carries the raw
+  // (decoded) ciphertext bytes.
+  private static final String CIPHERTEXT_BLOB =
+      
Base64.getEncoder().encodeToString("wrapped-key-material".getBytes(StandardCharsets.UTF_8));
+  private static final ByteBuffer WRAPPED_KEY =
+      ByteBuffer.wrap(Base64.getDecoder().decode(CIPHERTEXT_BLOB));
+
+  // shared with the reflectively-loaded MockClientFactory below; ThreadLocal 
keeps it
+  // parallel-safe.
+  private static final ThreadLocal<Client> MOCK_KMS = new ThreadLocal<>();
+
+  @BeforeEach
+  public void before() {
+    MOCK_KMS.set(mock(Client.class));
+  }
+
+  @AfterEach
+  public void after() {
+    MOCK_KMS.remove();
+  }
+
+  private static Client mockKms() {
+    return MOCK_KMS.get();
+  }
+
+  private static void stubDecrypt(String responseKeyId) throws Exception {
+    DecryptResponse response =
+        new DecryptResponse()
+            .setBody(new 
DecryptResponseBody().setPlaintext(RAW_KEY_B64).setKeyId(responseKeyId));
+    when(mockKms().decryptWithOptions(any(DecryptRequest.class), 
any(RuntimeOptions.class)))
+        .thenReturn(response);
+  }
+
+  @Test
+  public void testCreateFromKmsType() {
+    KeyManagementClient client =
+        EncryptionUtil.createKmsClient(
+            ImmutableMap.of(
+                CatalogProperties.ENCRYPTION_KMS_TYPE,
+                CatalogProperties.ENCRYPTION_KMS_TYPE_ALIYUN));
+    assertThat(client).isInstanceOf(AliyunKeyManagementClient.class);
+  }
+
+  @Test
+  public void testWrapKey() throws Exception {
+    EncryptResponse response =
+        new EncryptResponse().setBody(new 
EncryptResponseBody().setCiphertextBlob(CIPHERTEXT_BLOB));
+    when(mockKms().encryptWithOptions(any(EncryptRequest.class), 
any(RuntimeOptions.class)))
+        .thenReturn(response);
+
+    KeyManagementClient client = kmsClient(ImmutableMap.of());
+
+    ByteBuffer wrapped = client.wrapKey(ByteBuffer.wrap(RAW_KEY), 
WRAPPING_KEY_ID);
+    assertThat(wrapped).isEqualTo(WRAPPED_KEY);
+
+    ArgumentCaptor<EncryptRequest> captor = 
ArgumentCaptor.forClass(EncryptRequest.class);
+    verify(mockKms()).encryptWithOptions(captor.capture(), 
any(RuntimeOptions.class));
+    assertThat(captor.getValue().getKeyId()).isEqualTo(WRAPPING_KEY_ID);
+    assertThat(captor.getValue().getPlaintext()).isEqualTo(RAW_KEY_B64);
+  }
+
+  @Test
+  public void testUnwrapKey() throws Exception {
+    stubDecrypt(WRAPPING_KEY_ID);
+
+    ByteBuffer unwrapped =
+        kmsClient(ImmutableMap.of()).unwrapKey(WRAPPED_KEY.duplicate(), 
WRAPPING_KEY_ID);
+    assertThat(unwrapped).isEqualTo(ByteBuffer.wrap(RAW_KEY));
+
+    ArgumentCaptor<DecryptRequest> captor = 
ArgumentCaptor.forClass(DecryptRequest.class);
+    verify(mockKms()).decryptWithOptions(captor.capture(), 
any(RuntimeOptions.class));
+    
assertThat(captor.getValue().getCiphertextBlob()).isEqualTo(CIPHERTEXT_BLOB);
+  }
+
+  @Test
+  public void testUnwrapKeyRejectsMismatchedKeyId() throws Exception {
+    stubDecrypt("some-other-key");
+
+    KeyManagementClient client = kmsClient(ImmutableMap.of());
+    assertThatThrownBy(() -> client.unwrapKey(WRAPPED_KEY.duplicate(), 
WRAPPING_KEY_ID))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessageContaining("some-other-key")
+        .hasMessageContaining(WRAPPING_KEY_ID);
+  }
+
+  @Test
+  public void testUnwrapKeyWithArnKeyId() throws Exception {
+    String arn = "acs:kms:cn-hangzhou:1234567890123456:key/" + SAMPLE_KEY_ID;
+    stubDecrypt(SAMPLE_KEY_ID);
+
+    // an ARN is normalized to its bare key id locally and matches the response
+    assertThat(kmsClient(ImmutableMap.of()).unwrapKey(WRAPPED_KEY.duplicate(), 
arn))
+        .isEqualTo(ByteBuffer.wrap(RAW_KEY));
+  }
+
+  @Test
+  public void testUnwrapKeySkipsVerificationForAlias() throws Exception {
+    // an alias skips key-id verification, so unwrap succeeds even when the 
response keyId differs
+    stubDecrypt(SAMPLE_KEY_ID);
+
+    assertThat(
+            kmsClient(ImmutableMap.of()).unwrapKey(WRAPPED_KEY.duplicate(), 
"alias/maps-data-key"))
+        .isEqualTo(ByteBuffer.wrap(RAW_KEY));
+  }
+
+  @ParameterizedTest
+  @MethodSource("org.apache.iceberg.TestHelpers#serializers")
+  public void testKmsClientSerialization(
+      TestHelpers.RoundTripSerializer<KeyManagementClient> 
roundTripSerializer) throws Exception {
+    when(mockKms().encryptWithOptions(any(EncryptRequest.class), 
any(RuntimeOptions.class)))
+        .thenReturn(
+            new EncryptResponse()
+                .setBody(new 
EncryptResponseBody().setCiphertextBlob(CIPHERTEXT_BLOB)));
+    stubDecrypt(WRAPPING_KEY_ID);
+
+    Map<String, String> baseProps =
+        ImmutableMap.of(
+            AliyunProperties.CLIENT_REGION, "cn-hangzhou",
+            AliyunProperties.KMS_CLIENT_MAX_ATTEMPTS, "5");
+
+    KeyManagementClient client = kmsClient(baseProps);
+    KeyManagementClient roundTripped = roundTripSerializer.apply(client);
+
+    // wrap + unwrap survive serialization
+    ByteBuffer wrapped = roundTripped.wrapKey(ByteBuffer.wrap(RAW_KEY), 
WRAPPING_KEY_ID);
+    assertThat(wrapped).isEqualTo(WRAPPED_KEY);
+    assertThat(client.unwrapKey(wrapped.duplicate(), WRAPPING_KEY_ID))
+        .isEqualTo(ByteBuffer.wrap(RAW_KEY));
+    assertThat(roundTripped.unwrapKey(wrapped.duplicate(), WRAPPING_KEY_ID))
+        .isEqualTo(ByteBuffer.wrap(RAW_KEY));
+  }
+
+  private KeyManagementClient kmsClient(Map<String, String> extraProps) {
+    Map<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put(AliyunProperties.CLIENT_FACTORY, 
MockClientFactory.class.getName())
+            .putAll(extraProps)
+            .build();
+    KeyManagementClient client = new AliyunKeyManagementClient();
+    client.initialize(properties);
+    return client;
+  }
+
+  /**
+   * Reflectively loaded by {@link AliyunKeyManagementClient} to hand back the 
mocked KMS client.
+   */
+  public static class MockClientFactory implements AliyunClientFactory {
+    private AliyunProperties aliyunProperties;
+
+    public MockClientFactory() {}
+
+    @Override
+    public OSS newOSSClient() {
+      return null;
+    }
+
+    @Override
+    public Client newKmsClient() {
+      return mockKms();
+    }
+
+    @Override
+    public void initialize(Map<String, String> properties) {
+      this.aliyunProperties = new AliyunProperties(properties);
+    }
+
+    @Override
+    public AliyunProperties aliyunProperties() {
+      return aliyunProperties;
+    }
+  }
+}
diff --git a/build.gradle b/build.gradle
index 66ce4a3ee0..df44af7e9e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -501,6 +501,7 @@ project(':iceberg-aliyun') {
     implementation project(':iceberg-common')
 
     compileOnly libs.aliyun.sdk.oss
+    compileOnly libs.aliyun.sdk.kms
     compileOnly libs.aliyun.credentials.java
     compileOnly libs.aliyun.tea
     compileOnly libs.jaxb.api
@@ -516,6 +517,8 @@ project(':iceberg-aliyun') {
     testImplementation platform(libs.jackson.bom)
     testImplementation 
"com.fasterxml.jackson.dataformat:jackson-dataformat-xml"
     testImplementation project(path: ':iceberg-api', configuration: 
'testArtifacts')
+    testImplementation libs.esotericsoftware.kryo
+    testImplementation libs.mockito.core
     testImplementation libs.junit.pioneer
   }
 }
diff --git a/core/src/main/java/org/apache/iceberg/CatalogProperties.java 
b/core/src/main/java/org/apache/iceberg/CatalogProperties.java
index 6b85ccbc87..8ad32bed03 100644
--- a/core/src/main/java/org/apache/iceberg/CatalogProperties.java
+++ b/core/src/main/java/org/apache/iceberg/CatalogProperties.java
@@ -174,6 +174,7 @@ public class CatalogProperties {
   public static final String ENCRYPTION_KMS_TYPE_AWS = "aws";
   public static final String ENCRYPTION_KMS_TYPE_AZURE = "azure";
   public static final String ENCRYPTION_KMS_TYPE_GCP = "gcp";
+  public static final String ENCRYPTION_KMS_TYPE_ALIYUN = "aliyun";
 
   public static final String ENCRYPTION_KMS_IMPL = "encryption.kms-impl";
   public static final String ENCRYPTION_KMS_IMPL_AWS =
@@ -182,4 +183,6 @@ public class CatalogProperties {
       "org.apache.iceberg.azure.keymanagement.AzureKeyManagementClient";
   public static final String ENCRYPTION_KMS_IMPL_GCP =
       "org.apache.iceberg.gcp.GcpKeyManagementClient";
+  public static final String ENCRYPTION_KMS_IMPL_ALIYUN =
+      "org.apache.iceberg.aliyun.AliyunKeyManagementClient";
 }
diff --git 
a/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java 
b/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java
index 382d244883..cab839a7a8 100644
--- a/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java
+++ b/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java
@@ -63,6 +63,8 @@ public class EncryptionUtil {
                 CatalogProperties.ENCRYPTION_KMS_IMPL_AZURE;
             case CatalogProperties.ENCRYPTION_KMS_TYPE_GCP ->
                 CatalogProperties.ENCRYPTION_KMS_IMPL_GCP;
+            case CatalogProperties.ENCRYPTION_KMS_TYPE_ALIYUN ->
+                CatalogProperties.ENCRYPTION_KMS_IMPL_ALIYUN;
             default -> throw new IllegalStateException("Unsupported KMS type: 
" + kmsType);
           };
     }
diff --git a/docs/docs/encryption.md b/docs/docs/encryption.md
index cbdce85e76..f8dcfcbe87 100644
--- a/docs/docs/encryption.md
+++ b/docs/docs/encryption.md
@@ -28,7 +28,7 @@ Currently, encryption is supported in the Hive and REST 
catalogs for tables with
 
 Two parameters are required to activate encryption of a table:
 
-1. Catalog property that specifies the KMS ("key management service"). It can 
be either `encryption.kms-type` for pre-defined KMS clients (`aws`, `azure` or 
`gcp`) or `encryption.kms-impl` with the client class path for custom KMS 
clients.
+1. Catalog property that specifies the KMS ("key management service"). It can 
be either `encryption.kms-type` for pre-defined KMS clients (`aws`, `azure`, 
`gcp` or `aliyun`) or `encryption.kms-impl` with the client class path for 
custom KMS clients.
 2. Table property `encryption.key-id`, that specifies the ID of a master key 
used to encrypt and decrypt the table. Master keys are stored and managed in 
the KMS.
 
 For more details on table encryption, see the "Appendix: Internals Overview" 
[subsection](#appendix-internals-overview).
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 481da364d4..a7294dca4c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -21,7 +21,8 @@
 
 [versions]
 activation = "1.1.1"
-aliyun-credentials-java = "0.3.12"
+aliyun-credentials-java = "1.0.3"
+aliyun-sdk-kms = "2.3.0"
 aliyun-sdk-oss = "3.18.5"
 aliyun-tea = "1.4.2"
 analyticsaccelerator = "1.3.1"
@@ -98,6 +99,7 @@ tez08 = { strictly = "0.8.4"}  # see rich version usage 
explanation above
 activation = { module = "javax.activation:activation", version.ref = 
"activation" }
 aircompressor = { module = "io.airlift:aircompressor", version.ref = 
"aircompressor" }
 aliyun-credentials-java = { module = "com.aliyun:credentials-java", 
version.ref = "aliyun-credentials-java" }
+aliyun-sdk-kms = { module = "com.aliyun:kms20160120", version.ref = 
"aliyun-sdk-kms" }
 aliyun-sdk-oss = { module = "com.aliyun.oss:aliyun-sdk-oss", version.ref = 
"aliyun-sdk-oss" }
 aliyun-tea = { module = "com.aliyun:tea", version.ref = "aliyun-tea" }
 analyticsaccelerator-s3 = { module = 
"software.amazon.s3.analyticsaccelerator:analyticsaccelerator-s3", version.ref 
= "analyticsaccelerator" }

Reply via email to