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

XiaoHongbo-Hope pushed a commit to branch release-2.1
in repository https://gitbox.apache.org/repos/asf/paimon.git

commit 917601f87cf8a4ed293fa28d7d1d723d408879f6
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Thu Sep 17 18:24:33 2026 +0800

    [api] Add an opt-in DLFOpenApiV4Signer for DlfNext endpoints (#9918)
---
 docs/docs/concepts/rest/dlf.md                     |   9 +-
 .../org/apache/paimon/rest/RESTCatalogOptions.java |   3 +-
 .../apache/paimon/rest/auth/DLFAuthProvider.java   |   6 +-
 .../paimon/rest/auth/DLFOpenApiV4Signer.java       | 330 +++++++++++++++++++++
 .../paimon/rest/auth/DLFOpenApiV4SignerTest.java   | 290 ++++++++++++++++++
 paimon-python/pypaimon/api/auth/__init__.py        |   2 +
 paimon-python/pypaimon/api/auth/dlf_provider.py    |   5 +-
 paimon-python/pypaimon/api/auth/dlf_signer.py      | 228 +++++++++++++-
 paimon-python/pypaimon/api/auth/factory.py         |   1 +
 paimon-python/pypaimon/common/options/config.py    |   3 +-
 .../pypaimon/tests/rest/dlf_signer_test.py         | 192 +++++++++++-
 11 files changed, 1058 insertions(+), 11 deletions(-)

diff --git a/docs/docs/concepts/rest/dlf.md b/docs/docs/concepts/rest/dlf.md
index c87cfab063..602555b4a8 100644
--- a/docs/docs/concepts/rest/dlf.md
+++ b/docs/docs/concepts/rest/dlf.md
@@ -143,6 +143,9 @@ The client selects a request signer from the configured 
endpoint unless
 | `https://cn-hangzhou-vpc.dlf.aliyuncs.com` | `default` |
 | `https://dlfnext.cn-hangzhou.aliyuncs.com` | `openapi` |
 
-URIs containing `dlfnext` select the OpenAPI signer; other URIs select the 
default signer. The
-client also infers the region from the URI. Set `dlf.region` explicitly if the 
endpoint does not
-contain a recognizable region, for example when using a custom hostname.
+URIs containing `dlfnext` select the OpenAPI signer; other URIs select the 
default signer. Set
+`dlf.signing-algorithm` to `openapi-v4` to sign with ACS4-HMAC-SHA256 instead, 
which hashes a
+canonical request and derives its key from the date, the region and the 
product. That signer is
+opt-in: no endpoint selects it on its own. The client also infers the region 
from the URI.
+Set `dlf.region` explicitly if the endpoint does not contain a recognizable 
region, for example when
+using a custom hostname.
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java
index e7da1f6827..46608cda8b 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTCatalogOptions.java
@@ -110,7 +110,8 @@ public class RESTCatalogOptions {
                     .defaultValue("default")
                     .withDescription(
                             "DLF signing algorithm. Options: 'default' (for 
default VPC endpoint), "
-                                    + "'openapi' (for DlfNext/2026-01-18). "
+                                    + "'openapi-v4' (ACS4-HMAC-SHA256, for 
DlfNext/2026-01-18), "
+                                    + "'openapi' (the earlier ROA HMAC-SHA1 
scheme). "
                                     + "If not set, will be automatically 
selected based on endpoint host.");
 
     public static final ConfigOption<Boolean> IO_CACHE_ENABLED =
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFAuthProvider.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFAuthProvider.java
index ee6b56472c..1f4c5dd1d7 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFAuthProvider.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFAuthProvider.java
@@ -141,6 +141,8 @@ public class DLFAuthProvider implements AuthProvider {
                 return new DLFDefaultSigner(region);
             case DLFOpenApiSigner.IDENTIFIER:
                 return new DLFOpenApiSigner();
+            case DLFOpenApiV4Signer.IDENTIFIER:
+                return new DLFOpenApiV4Signer(region);
             default:
                 throw new IllegalArgumentException(
                         "Unknown DLF signing algorithm: "
@@ -148,7 +150,9 @@ public class DLFAuthProvider implements AuthProvider {
                                 + ". Supported: "
                                 + DLFDefaultSigner.IDENTIFIER
                                 + ", "
-                                + DLFOpenApiSigner.IDENTIFIER);
+                                + DLFOpenApiSigner.IDENTIFIER
+                                + ", "
+                                + DLFOpenApiV4Signer.IDENTIFIER);
         }
     }
 
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java
new file mode 100644
index 0000000000..d82103a9ec
--- /dev/null
+++ 
b/paimon-api/src/main/java/org/apache/paimon/rest/auth/DLFOpenApiV4Signer.java
@@ -0,0 +1,330 @@
+/*
+ * 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.paimon.rest.auth;
+
+import javax.annotation.Nullable;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.apache.paimon.rest.RESTUtil.decodeString;
+
+/**
+ * Signer for Aliyun OpenAPI V4 requests, using the ACS4-HMAC-SHA256 algorithm.
+ *
+ * <p>Unlike {@link DLFOpenApiSigner}, which signs a string built from the 
{@code Date} and {@code
+ * Content-MD5} headers with HMAC-SHA1, this signer hashes a canonical request 
and signs it with a
+ * key derived from the date, the region and the product, the way the POP 
gateway does.
+ *
+ * <p>Reference: 
https://github.com/aliyun/alibabacloud-gateway/tree/master/alibabacloud-gateway-pop
+ */
+public class DLFOpenApiV4Signer implements DLFRequestSigner {
+
+    public static final String IDENTIFIER = "openapi-v4";
+
+    private static final String SIGNATURE_ALGORITHM = "ACS4-HMAC-SHA256";
+    private static final String HMAC_SHA256 = "HmacSHA256";
+
+    /** Key derivation prefix and terminator, shared with the DLF4 scheme. */
+    private static final String SIGN_PREFIX = "aliyun_v4";
+
+    private static final String REQUEST_TYPE = SIGN_PREFIX + "_request";
+
+    /** POP product code; it is the third level of the credential scope. */
+    private static final String PRODUCT = "DlfNext";
+
+    private static final String HOST_HEADER = "host";
+    private static final String CONTENT_TYPE_HEADER = "content-type";
+    private static final String X_ACS_DATE = "x-acs-date";
+    private static final String X_ACS_SIGNATURE_NONCE = 
"x-acs-signature-nonce";
+    private static final String X_ACS_VERSION = "x-acs-version";
+    private static final String X_ACS_CONTENT_SHA256 = "x-acs-content-sha256";
+    private static final String X_ACS_SECURITY_TOKEN = "x-acs-security-token";
+
+    private static final String CONTENT_TYPE_VALUE = "application/json";
+    private static final String API_VERSION = "2026-01-18";
+
+    private static final DateTimeFormatter ACS_DATE_FORMATTER =
+            
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC);
+
+    private static final String EMPTY_BODY_SHA256 =
+            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+
+    private final String region;
+
+    public DLFOpenApiV4Signer(String region) {
+        if (region == null) {
+            throw new IllegalArgumentException("Parameter 'region' cannot be 
null");
+        }
+        this.region = region;
+    }
+
+    @Override
+    public Map<String, String> signHeaders(
+            @Nullable String body, Instant now, @Nullable String 
securityToken, String host) {
+        if (now == null) {
+            throw new IllegalArgumentException("Parameter 'now' cannot be 
null");
+        }
+        if (host == null) {
+            throw new IllegalArgumentException("Parameter 'host' cannot be 
null");
+        }
+
+        Map<String, String> headers = new HashMap<>();
+
+        // x-acs-date (ISO 8601, always UTC)
+        headers.put(
+                X_ACS_DATE,
+                ACS_DATE_FORMATTER.format(ZonedDateTime.ofInstant(now, 
ZoneOffset.UTC)));
+
+        headers.put(HOST_HEADER, host);
+
+        // An absent body hashes to the well-known SHA-256 of the empty string
+        if (body != null && !body.isEmpty()) {
+            headers.put(X_ACS_CONTENT_SHA256, sha256Hex(body));
+            headers.put(CONTENT_TYPE_HEADER, CONTENT_TYPE_VALUE);
+        } else {
+            headers.put(X_ACS_CONTENT_SHA256, EMPTY_BODY_SHA256);
+        }
+
+        headers.put(X_ACS_SIGNATURE_NONCE, generateUniqueNonce());
+        headers.put(X_ACS_VERSION, API_VERSION);
+
+        if (securityToken != null) {
+            headers.put(X_ACS_SECURITY_TOKEN, securityToken);
+        }
+
+        return headers;
+    }
+
+    /** Generates a unique nonce: UUID + timestamp + thread ID. */
+    private static String generateUniqueNonce() {
+        return UUID.randomUUID().toString()
+                + System.currentTimeMillis()
+                + Thread.currentThread().getId();
+    }
+
+    @Override
+    public String authorization(
+            RESTAuthParameter restAuthParameter,
+            DLFToken token,
+            String host,
+            Map<String, String> signHeaders)
+            throws Exception {
+        if (restAuthParameter == null) {
+            throw new IllegalArgumentException("Parameter 'restAuthParameter' 
cannot be null");
+        }
+        if (token == null) {
+            throw new IllegalArgumentException("Parameter 'token' cannot be 
null");
+        }
+        if (host == null) {
+            throw new IllegalArgumentException("Parameter 'host' cannot be 
null");
+        }
+        if (signHeaders == null) {
+            throw new IllegalArgumentException("Parameter 'signHeaders' cannot 
be null");
+        }
+
+        CanonicalHeadersResult canonicalHeadersResult = 
buildCanonicalHeaders(signHeaders);
+        String canonicalQueryString = 
buildCanonicalQueryString(restAuthParameter.parameters());
+
+        String canonicalUri = restAuthParameter.resourcePath();
+        if (canonicalUri == null || canonicalUri.trim().isEmpty()) {
+            canonicalUri = "/";
+        }
+
+        String hashedPayload = signHeaders.getOrDefault(X_ACS_CONTENT_SHA256, 
EMPTY_BODY_SHA256);
+
+        String canonicalRequest =
+                restAuthParameter.method()
+                        + "\n"
+                        + canonicalUri
+                        + "\n"
+                        + canonicalQueryString
+                        + "\n"
+                        + canonicalHeadersResult.canonicalHeaders
+                        + "\n"
+                        + canonicalHeadersResult.signedHeaders
+                        + "\n"
+                        + hashedPayload;
+
+        String stringToSign = SIGNATURE_ALGORITHM + "\n" + 
sha256Hex(canonicalRequest);
+
+        // The credential scope date comes from the signed x-acs-date, so the 
two cannot drift
+        String date = credentialScopeDate(signHeaders.get(X_ACS_DATE));
+        byte[] signingKey = signingKey(token.getAccessKeySecret(), date);
+        String signature = hexEncode(hmacSha256(signingKey, stringToSign));
+
+        return SIGNATURE_ALGORITHM
+                + " Credential="
+                + token.getAccessKeyId()
+                + "/"
+                + date
+                + "/"
+                + region
+                + "/"
+                + PRODUCT
+                + "/"
+                + REQUEST_TYPE
+                + ",SignedHeaders="
+                + canonicalHeadersResult.signedHeaders
+                + ",Signature="
+                + signature;
+    }
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    /** Turns {@code 2025-04-16T03:44:46Z} into {@code 20250416}. */
+    private static String credentialScopeDate(@Nullable String acsDate) {
+        if (acsDate == null || acsDate.length() < 10) {
+            throw new IllegalArgumentException("Header '" + X_ACS_DATE + "' is 
missing or invalid");
+        }
+        return acsDate.substring(0, 10).replace("-", "");
+    }
+
+    /** Derives the signing key: secret, then date, region, product and 
request type in turn. */
+    private byte[] signingKey(String accessKeySecret, String date) throws 
Exception {
+        byte[] dateKey =
+                hmacSha256((SIGN_PREFIX + 
accessKeySecret).getBytes(StandardCharsets.UTF_8), date);
+        byte[] dateRegionKey = hmacSha256(dateKey, region);
+        byte[] dateRegionProductKey = hmacSha256(dateRegionKey, PRODUCT);
+        return hmacSha256(dateRegionProductKey, REQUEST_TYPE);
+    }
+
+    /** The canonical header block and the {@code SignedHeaders} list that 
describes it. */
+    static class CanonicalHeadersResult {
+        final String canonicalHeaders;
+        final String signedHeaders;
+
+        CanonicalHeadersResult(String canonicalHeaders, String signedHeaders) {
+            this.canonicalHeaders = canonicalHeaders;
+            this.signedHeaders = signedHeaders;
+        }
+    }
+
+    /** Signs host, content-type and every x-acs-* header, sorted and 
lowercased. */
+    private static CanonicalHeadersResult buildCanonicalHeaders(Map<String, 
String> headers) {
+        List<String> canonicalizedKeys = new ArrayList<>();
+        Map<String, String> valueMap = new HashMap<>();
+
+        for (Map.Entry<String, String> entry : headers.entrySet()) {
+            String lowerKey = entry.getKey().toLowerCase();
+            if (lowerKey.startsWith("x-acs-")
+                    || lowerKey.equals(HOST_HEADER)
+                    || lowerKey.equals(CONTENT_TYPE_HEADER)) {
+                if (!canonicalizedKeys.contains(lowerKey)) {
+                    canonicalizedKeys.add(lowerKey);
+                }
+                valueMap.put(lowerKey, entry.getValue().trim());
+            }
+        }
+
+        String[] sortedKeys = canonicalizedKeys.toArray(new String[0]);
+        Arrays.sort(sortedKeys);
+
+        StringBuilder sb = new StringBuilder();
+        for (String key : sortedKeys) {
+            sb.append(key).append(":").append(valueMap.get(key)).append("\n");
+        }
+
+        return new CanonicalHeadersResult(sb.toString(), String.join(";", 
sortedKeys));
+    }
+
+    /** Build canonical query string with percent-encoding (RFC 3986). */
+    private static String buildCanonicalQueryString(Map<String, String> 
params) throws Exception {
+        if (params == null || params.isEmpty()) {
+            return "";
+        }
+
+        String[] keys = params.keySet().toArray(new String[0]);
+        Arrays.sort(keys);
+
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < keys.length; i++) {
+            if (i > 0) {
+                sb.append("&");
+            }
+            sb.append(percentEncode(keys[i]));
+            sb.append("=");
+            String value = params.get(keys[i]);
+            if (value != null && !value.isEmpty()) {
+                // RESTAuthParameter has already encoded the value once; 
decode it so the
+                // canonical form matches what the gateway rebuilds from the 
wire
+                sb.append(percentEncode(decodeString(value)));
+            }
+        }
+        return sb.toString();
+    }
+
+    /** RFC 3986 percent-encoding. */
+    static String percentEncode(String value) throws 
UnsupportedEncodingException {
+        if (value == null) {
+            return null;
+        }
+        return URLEncoder.encode(value, "UTF-8")
+                .replace("+", "%20")
+                .replace("*", "%2A")
+                .replace("%7E", "~");
+    }
+
+    private static String sha256Hex(String data) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            return 
hexEncode(digest.digest(data.getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to calculate SHA-256", e);
+        }
+    }
+
+    private static byte[] hmacSha256(byte[] key, String data) throws Exception 
{
+        Mac mac = Mac.getInstance(HMAC_SHA256);
+        mac.init(new SecretKeySpec(key, HMAC_SHA256));
+        return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static String hexEncode(byte[] raw) {
+        if (raw == null) {
+            return null;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (byte b : raw) {
+            String hex = Integer.toHexString(b & 0xFF);
+            if (hex.length() < 2) {
+                sb.append(0);
+            }
+            sb.append(hex);
+        }
+        return sb.toString();
+    }
+}
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
new file mode 100644
index 0000000000..540b2c82db
--- /dev/null
+++ 
b/paimon-api/src/test/java/org/apache/paimon/rest/auth/DLFOpenApiV4SignerTest.java
@@ -0,0 +1,290 @@
+/*
+ * 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.paimon.rest.auth;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** Test for {@link DLFOpenApiV4Signer}. */
+public class DLFOpenApiV4SignerTest {
+
+    private static final String EMPTY_BODY_SHA256 =
+            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
+
+    private static final String HOST = "dlfnext.cn-beijing.aliyuncs.com";
+
+    private static final String REGION = "cn-beijing";
+
+    private static final Instant NOW =
+            ZonedDateTime.of(2025, 4, 16, 3, 44, 46, 0, 
ZoneOffset.UTC).toInstant();
+
+    @Test
+    public void testSignHeadersWithBody() {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        String body = 
"{\"CategoryName\":\"test\",\"CategoryType\":\"UNSTRUCTURED\"}";
+
+        Map<String, String> headers = signer.signHeaders(body, NOW, null, 
HOST);
+
+        assertEquals("2025-04-16T03:44:46Z", headers.get("x-acs-date"));
+        assertEquals(HOST, headers.get("host"));
+        assertNotEquals(EMPTY_BODY_SHA256, 
headers.get("x-acs-content-sha256"));
+        assertEquals("application/json", headers.get("content-type"));
+        assertNotNull(headers.get("x-acs-signature-nonce"));
+        assertEquals("2026-01-18", headers.get("x-acs-version"));
+
+        // The V1 ROA headers belong to DLFOpenApiSigner, not to this signer
+        assertFalse(headers.containsKey("Date"));
+        assertFalse(headers.containsKey("Accept"));
+        assertFalse(headers.containsKey("Content-MD5"));
+        assertFalse(headers.containsKey("x-acs-signature-method"));
+        assertFalse(headers.containsKey("x-acs-signature-version"));
+    }
+
+    @Test
+    public void testSignHeadersWithoutBody() {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+
+        Map<String, String> headers = signer.signHeaders(null, NOW, null, 
HOST);
+
+        assertEquals(EMPTY_BODY_SHA256, headers.get("x-acs-content-sha256"));
+        assertFalse(headers.containsKey("content-type"));
+    }
+
+    @Test
+    public void testSignHeadersWithSecurityToken() {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+
+        Map<String, String> headers = signer.signHeaders(null, NOW, 
"test-security-token", HOST);
+
+        assertEquals("test-security-token", 
headers.get("x-acs-security-token"));
+    }
+
+    @Test
+    public void testDateIsRenderedInUtcForNonUtcInstant() {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        Instant beijingNoon =
+                ZonedDateTime.of(2025, 4, 16, 11, 44, 46, 0, 
ZoneOffset.ofHours(8)).toInstant();
+
+        Map<String, String> headers = signer.signHeaders(null, beijingNoon, 
null, HOST);
+
+        assertEquals("2025-04-16T03:44:46Z", headers.get("x-acs-date"));
+    }
+
+    /** The credential scope carries the date, the region and the POP product 
code. */
+    @Test
+    public void testAuthorizationCarriesCredentialScope() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        DLFToken token =
+                new DLFToken("YourAccessKeyId", "YourAccessKeySecret", 
"securityToken", null);
+        String body = 
"{\"CategoryName\":\"test\",\"CategoryType\":\"UNSTRUCTURED\"}";
+
+        Map<String, String> signHeaders =
+                signer.signHeaders(body, NOW, token.getSecurityToken(), HOST);
+        signHeaders.put("x-acs-signature-nonce", 
"ef34aae7-7bd2-413d-a541-680cd2c48538");
+
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter(
+                        "/llm-p2e4XXXXXXXXsvtn/datacenter/category", new 
HashMap<>(), "POST", body);
+
+        String authorization = signer.authorization(restAuthParameter, token, 
HOST, signHeaders);
+
+        assertTrue(authorization.startsWith("ACS4-HMAC-SHA256 Credential="));
+        assertTrue(
+                authorization.contains(
+                        
"Credential=YourAccessKeyId/20250416/cn-beijing/DlfNext/aliyun_v4_request,"));
+        assertTrue(authorization.contains("SignedHeaders="));
+
+        String signaturePart = 
authorization.substring(authorization.indexOf("Signature=") + 10);
+        assertEquals(64, signaturePart.length());
+        assertTrue(signaturePart.matches("[0-9a-f]+"));
+    }
+
+    /** The derived key depends on the region, so the same request signs 
differently elsewhere. */
+    @Test
+    public void testSignatureDependsOnRegion() throws Exception {
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter("/test/path", new HashMap<>(), "GET", 
null);
+
+        DLFOpenApiV4Signer beijing = new DLFOpenApiV4Signer("cn-beijing");
+        Map<String, String> headers = beijing.signHeaders(null, NOW, null, 
HOST);
+        headers.put("x-acs-signature-nonce", "fixed-nonce-for-test");
+        String beijingAuth = beijing.authorization(restAuthParameter, token, 
HOST, headers);
+
+        DLFOpenApiV4Signer hangzhou = new DLFOpenApiV4Signer("cn-hangzhou");
+        String hangzhouAuth = hangzhou.authorization(restAuthParameter, token, 
HOST, headers);
+
+        assertNotEquals(beijingAuth, hangzhouAuth);
+        assertTrue(beijingAuth.contains("/cn-beijing/DlfNext/"));
+        assertTrue(hangzhouAuth.contains("/cn-hangzhou/DlfNext/"));
+    }
+
+    @Test
+    public void testDeterministicSignature() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+
+        Map<String, String> signHeaders = signer.signHeaders(null, NOW, null, 
HOST);
+        signHeaders.put("x-acs-signature-nonce", "fixed-nonce-for-test");
+
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter("/test/path", new HashMap<>(), "GET", 
null);
+
+        String auth1 = signer.authorization(restAuthParameter, token, HOST, 
signHeaders);
+        String auth2 = signer.authorization(restAuthParameter, token, HOST, 
signHeaders);
+
+        assertEquals(auth1, auth2);
+    }
+
+    @Test
+    public void testSignatureCoversQueryParameters() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+
+        Map<String, String> signHeaders = signer.signHeaders(null, NOW, null, 
HOST);
+        signHeaders.put("x-acs-signature-nonce", "fixed-nonce-for-test");
+
+        Map<String, String> queryParams = new HashMap<>();
+        queryParams.put("k2", "v2");
+        queryParams.put("k1", "v1");
+
+        String withParams =
+                signer.authorization(
+                        new RESTAuthParameter("/test/path", queryParams, 
"GET", null),
+                        token,
+                        HOST,
+                        signHeaders);
+        String withoutParams =
+                signer.authorization(
+                        new RESTAuthParameter("/test/path", new HashMap<>(), 
"GET", null),
+                        token,
+                        HOST,
+                        signHeaders);
+
+        assertTrue(withParams.startsWith("ACS4-HMAC-SHA256 "));
+        assertNotEquals(withoutParams, withParams);
+    }
+
+    /**
+     * Known answer for query values that change under encoding. {@link 
RESTAuthParameter} has
+     * already encoded them once, so encoding them again would sign a 
canonical form the gateway
+     * cannot rebuild from the wire. The keys are inserted out of order, so 
dropping the sort breaks
+     * this too. pypaimon pins the same string.
+     */
+    @Test
+    public void testKnownSignatureForEncodedQueryValues() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer("cn-hangzhou");
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+        String host = "dlfnext.cn-hangzhou.aliyuncs.com";
+
+        Map<String, String> signHeaders = signer.signHeaders(null, NOW, null, 
host);
+        signHeaders.put("x-acs-signature-nonce", "fixed-nonce-for-test");
+
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("pageToken", "a b/c");
+        params.put("functionNamePattern", "func%");
+
+        String authorization =
+                signer.authorization(
+                        new RESTAuthParameter("/v1/prefix/functions", params, 
"GET", null),
+                        token,
+                        host,
+                        signHeaders);
+
+        assertEquals(
+                "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,"
+                        + 
"SignedHeaders=host;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;"
+                        + "x-acs-version,"
+                        + 
"Signature=9812bacd92dd1152e7ea32aacfad0545835d6a18543852364f1a1ea0cba6219a",
+                authorization);
+    }
+
+    @Test
+    public void testSignedHeadersIncludeHostAndContentType() throws Exception {
+        DLFOpenApiV4Signer signer = new DLFOpenApiV4Signer(REGION);
+        DLFToken token = new DLFToken("TestAKId", "TestAKSecret", null, null);
+        String body = "{\"test\":\"data\"}";
+
+        Map<String, String> signHeaders = signer.signHeaders(body, NOW, null, 
HOST);
+        signHeaders.put("x-acs-signature-nonce", "fixed-nonce");
+
+        RESTAuthParameter restAuthParameter =
+                new RESTAuthParameter("/test/path", new HashMap<>(), "POST", 
body);
+
+        String authorization = signer.authorization(restAuthParameter, token, 
HOST, signHeaders);
+
+        String signedHeadersPart =
+                authorization.substring(
+                        authorization.indexOf("SignedHeaders=") + 14,
+                        authorization.indexOf(",Signature="));
+        // exact, so that dropping the sort in buildCanonicalHeaders fails here
+        assertEquals(
+                
"content-type;host;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;"
+                        + "x-acs-version",
+                signedHeadersPart);
+    }
+
+    @Test
+    public void testPercentEncode() throws Exception {
+        assertEquals("hello%20world", DLFOpenApiV4Signer.percentEncode("hello 
world"));
+        assertEquals("a%2Ab", DLFOpenApiV4Signer.percentEncode("a*b"));
+        assertEquals("a~b", DLFOpenApiV4Signer.percentEncode("a~b"));
+        assertEquals("a%2Fb", DLFOpenApiV4Signer.percentEncode("a/b"));
+    }
+
+    @Test
+    public void testIdentifier() {
+        assertEquals("openapi-v4", new 
DLFOpenApiV4Signer(REGION).identifier());
+    }
+
+    @Test
+    public void testAuthProviderSignsWithAcs4WhenConfigured() {
+        String uri = "https://dlfnext.cn-hangzhou.aliyuncs.com";;
+        DLFAuthProvider provider =
+                DLFAuthProvider.fromAccessKey(
+                        "akId",
+                        "akSecret",
+                        null,
+                        uri,
+                        "cn-hangzhou",
+                        DLFOpenApiV4Signer.IDENTIFIER);
+
+        Map<String, String> headers =
+                provider.mergeAuthHeader(
+                        new HashMap<>(),
+                        new RESTAuthParameter("/test/path", new HashMap<>(), 
"GET", null));
+
+        assertTrue(headers.get("Authorization").startsWith("ACS4-HMAC-SHA256 
Credential=akId/"));
+        assertTrue(
+                
headers.get("Authorization").contains("/cn-hangzhou/DlfNext/aliyun_v4_request,"));
+        assertNotNull(headers.get("x-acs-date"));
+    }
+}
diff --git a/paimon-python/pypaimon/api/auth/__init__.py 
b/paimon-python/pypaimon/api/auth/__init__.py
index 49992eaf8f..97da7b90df 100644
--- a/paimon-python/pypaimon/api/auth/__init__.py
+++ b/paimon-python/pypaimon/api/auth/__init__.py
@@ -23,6 +23,7 @@ from pypaimon.api.auth.dlf_provider import DLFAuthProvider
 from pypaimon.api.auth.dlf_signer import (
     DLFDefaultSigner,
     DLFOpenApiSigner,
+    DLFOpenApiV4Signer,
     DLFRequestSigner,
 )
 from pypaimon.api.auth.factory import AuthProviderFactory, 
DLFAuthProviderFactory
@@ -36,5 +37,6 @@ __all__ = [
     "DLFRequestSigner",
     "DLFDefaultSigner",
     "DLFOpenApiSigner",
+    "DLFOpenApiV4Signer",
     "DLFAuthProvider",
 ]
diff --git a/paimon-python/pypaimon/api/auth/dlf_provider.py 
b/paimon-python/pypaimon/api/auth/dlf_provider.py
index bcde2db767..14c38db5b0 100644
--- a/paimon-python/pypaimon/api/auth/dlf_provider.py
+++ b/paimon-python/pypaimon/api/auth/dlf_provider.py
@@ -25,6 +25,7 @@ from pypaimon.api.auth.base import AuthProvider
 from pypaimon.api.auth.dlf_signer import (
     DLFDefaultSigner,
     DLFOpenApiSigner,
+    DLFOpenApiV4Signer,
     DLFRequestSigner,
 )
 from pypaimon.api.token_loader import DLFToken, DLFTokenLoader
@@ -52,7 +53,9 @@ class DLFAuthProvider(AuthProvider):
         self.signer = self._create_signer(signing_algorithm)
 
     def _create_signer(self, signing_algorithm: str) -> DLFRequestSigner:
-        if signing_algorithm == DLFOpenApiSigner.IDENTIFIER:
+        if signing_algorithm == DLFOpenApiV4Signer.IDENTIFIER:
+            return DLFOpenApiV4Signer(self.region)
+        elif signing_algorithm == DLFOpenApiSigner.IDENTIFIER:
             return DLFOpenApiSigner()
         else:
             return DLFDefaultSigner(self.region)
diff --git a/paimon-python/pypaimon/api/auth/dlf_signer.py 
b/paimon-python/pypaimon/api/auth/dlf_signer.py
index 94ec6eb866..9915fa012d 100644
--- a/paimon-python/pypaimon/api/auth/dlf_signer.py
+++ b/paimon-python/pypaimon/api/auth/dlf_signer.py
@@ -24,8 +24,8 @@ import uuid
 from abc import ABC, abstractmethod
 from collections import OrderedDict
 from datetime import datetime, timezone
-from typing import Dict, Optional
-from urllib.parse import unquote
+from typing import Dict, NamedTuple, Optional
+from urllib.parse import quote, unquote
 
 from pypaimon.api.token_loader import DLFToken
 from pypaimon.api.typedef import RESTAuthParameter
@@ -497,3 +497,227 @@ class DLFOpenApiSigner(DLFRequestSigner):
     @staticmethod
     def _trim(value: str) -> str:
         return value.strip() if value else ""
+
+
+class CanonicalHeadersResult(NamedTuple):
+    """The canonical header block and the SignedHeaders list that describes 
it."""
+    canonical_headers: str
+    signed_headers: str
+
+
+class DLFOpenApiV4Signer(DLFRequestSigner):
+    """
+    Signer for Aliyun OpenAPI V4 requests, using the ACS4-HMAC-SHA256 
algorithm.
+
+    Unlike DLFOpenApiSigner, which signs a string built from the Date and
+    Content-MD5 headers with HMAC-SHA1, this signer hashes a canonical request
+    and signs it with a key derived from the date, the region and the product,
+    the way the POP gateway does.
+
+    Reference: 
https://github.com/aliyun/alibabacloud-gateway/tree/master/alibabacloud-gateway-pop
+    """
+
+    IDENTIFIER = "openapi-v4"
+
+    SIGNATURE_ALGORITHM = "ACS4-HMAC-SHA256"
+
+    # Key derivation prefix and terminator, shared with the DLF4 scheme
+    SIGN_PREFIX = "aliyun_v4"
+    REQUEST_TYPE = "aliyun_v4_request"
+
+    # POP product code; it is the third level of the credential scope
+    PRODUCT = "DlfNext"
+
+    # Header constants
+    HOST_HEADER = "host"
+    CONTENT_TYPE_HEADER = "content-type"
+    X_ACS_DATE = "x-acs-date"
+    X_ACS_SIGNATURE_NONCE = "x-acs-signature-nonce"
+    X_ACS_VERSION = "x-acs-version"
+    X_ACS_CONTENT_SHA256 = "x-acs-content-sha256"
+    X_ACS_SECURITY_TOKEN = "x-acs-security-token"
+
+    # Values
+    ACS_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
+    CONTENT_TYPE_VALUE = "application/json"
+    API_VERSION = "2026-01-18"
+
+    EMPTY_BODY_SHA256 = 
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+    def __init__(self, region: str):
+        if region is None:
+            raise ValueError("Parameter 'region' cannot be None")
+        self.region = region
+
+    def sign_headers(
+            self,
+            body: Optional[str],
+            now: datetime,
+            security_token: Optional[str],
+            host: str
+    ) -> Dict[str, str]:
+        if now is None:
+            raise ValueError("Parameter 'now' cannot be None")
+        if host is None:
+            raise ValueError("Parameter 'host' cannot be None")
+
+        headers = {}
+
+        # x-acs-date (ISO 8601, always UTC)
+        if now.tzinfo is None:
+            utc_now = now.replace(tzinfo=timezone.utc)
+        else:
+            utc_now = now.astimezone(timezone.utc)
+        headers[self.X_ACS_DATE] = utc_now.strftime(self.ACS_DATE_FORMAT)
+
+        headers[self.HOST_HEADER] = host
+
+        # An absent body hashes to the well-known SHA-256 of the empty string
+        if body is not None and body != "":
+            headers[self.X_ACS_CONTENT_SHA256] = self._sha256_hex(body)
+            headers[self.CONTENT_TYPE_HEADER] = self.CONTENT_TYPE_VALUE
+        else:
+            headers[self.X_ACS_CONTENT_SHA256] = self.EMPTY_BODY_SHA256
+
+        headers[self.X_ACS_SIGNATURE_NONCE] = self._generate_unique_nonce()
+        headers[self.X_ACS_VERSION] = self.API_VERSION
+
+        if security_token is not None:
+            headers[self.X_ACS_SECURITY_TOKEN] = security_token
+
+        return headers
+
+    def authorization(
+            self,
+            rest_auth_parameter: RESTAuthParameter,
+            token: DLFToken,
+            host: str,
+            sign_headers: Dict[str, str]
+    ) -> str:
+        if rest_auth_parameter is None:
+            raise ValueError("Parameter 'rest_auth_parameter' cannot be None")
+        if token is None:
+            raise ValueError("Parameter 'token' cannot be None")
+        if host is None:
+            raise ValueError("Parameter 'host' cannot be None")
+        if sign_headers is None:
+            raise ValueError("Parameter 'sign_headers' cannot be None")
+
+        try:
+            canonical = self._build_canonical_headers(sign_headers)
+            canonical_query_string = self._build_canonical_query_string(
+                rest_auth_parameter.parameters
+            )
+
+            canonical_uri = rest_auth_parameter.path
+            if not canonical_uri or not canonical_uri.strip():
+                canonical_uri = "/"
+
+            hashed_payload = sign_headers.get(
+                self.X_ACS_CONTENT_SHA256, self.EMPTY_BODY_SHA256
+            )
+
+            canonical_request = "\n".join([
+                rest_auth_parameter.method,
+                canonical_uri,
+                canonical_query_string,
+                canonical.canonical_headers,
+                canonical.signed_headers,
+                hashed_payload,
+            ])
+
+            string_to_sign = self.SIGNATURE_ALGORITHM + "\n" + 
self._sha256_hex(canonical_request)
+
+            # The credential scope date comes from the signed x-acs-date, so 
the two cannot drift
+            date = 
self._credential_scope_date(sign_headers.get(self.X_ACS_DATE))
+            signing_key = self._signing_key(token.access_key_secret, date)
+            signature = self._hmac_sha256(signing_key, string_to_sign).hex()
+
+            credential_scope = 
f"{date}/{self.region}/{self.PRODUCT}/{self.REQUEST_TYPE}"
+            return (
+                f"{self.SIGNATURE_ALGORITHM} "
+                f"Credential={token.access_key_id}/{credential_scope},"
+                f"SignedHeaders={canonical.signed_headers},"
+                f"Signature={signature}"
+            )
+
+        except Exception as e:
+            raise RuntimeError(f"Failed to generate authorization: {e}")
+
+    def identifier(self) -> str:
+        return self.IDENTIFIER
+
+    @staticmethod
+    def _credential_scope_date(acs_date: Optional[str]) -> str:
+        """Turns 2025-04-16T03:44:46Z into 20250416."""
+        if acs_date is None or len(acs_date) < 10:
+            raise ValueError("Header 'x-acs-date' is missing or invalid")
+        return acs_date[:10].replace("-", "")
+
+    def _signing_key(self, access_key_secret: str, date: str) -> bytes:
+        """Derives the signing key: secret, then date, region, product and 
request type."""
+        date_key = self._hmac_sha256(
+            f"{self.SIGN_PREFIX}{access_key_secret}".encode("utf-8"), date
+        )
+        date_region_key = self._hmac_sha256(date_key, self.region)
+        date_region_product_key = self._hmac_sha256(date_region_key, 
self.PRODUCT)
+        return self._hmac_sha256(date_region_product_key, self.REQUEST_TYPE)
+
+    @staticmethod
+    def _build_canonical_headers(headers: Dict[str, str]) -> 
CanonicalHeadersResult:
+        """Signs host, content-type and every x-acs-* header, sorted and 
lowercased."""
+        canonicalized_keys = []
+        value_map = {}
+
+        for key, value in headers.items():
+            lower_key = key.lower()
+            if (lower_key.startswith("x-acs-")
+                    or lower_key == DLFOpenApiV4Signer.HOST_HEADER
+                    or lower_key == DLFOpenApiV4Signer.CONTENT_TYPE_HEADER):
+                if lower_key not in canonicalized_keys:
+                    canonicalized_keys.append(lower_key)
+                value_map[lower_key] = value.strip()
+
+        canonicalized_keys.sort()
+        canonical_headers = "".join(f"{key}:{value_map[key]}\n" for key in 
canonicalized_keys)
+
+        return CanonicalHeadersResult(canonical_headers, 
";".join(canonicalized_keys))
+
+    @staticmethod
+    def _build_canonical_query_string(parameters: Optional[Dict[str, str]]) -> 
str:
+        """Build canonical query string with percent-encoding (RFC 3986)."""
+        if not parameters:
+            return ""
+
+        parts = []
+        for key in sorted(parameters.keys()):
+            value = parameters[key]
+            encoded_key = DLFOpenApiV4Signer._percent_encode(key)
+            if value is not None and value != "":
+                # RESTAuthParameter has already encoded the value once; decode 
it so the
+                # canonical form matches what the gateway rebuilds from the 
wire
+                decoded = DLFOpenApiV4Signer._percent_encode(unquote(value))
+                parts.append(f"{encoded_key}={decoded}")
+            else:
+                parts.append(f"{encoded_key}=")
+        return "&".join(parts)
+
+    @staticmethod
+    def _percent_encode(value: str) -> Optional[str]:
+        """RFC 3986 percent-encoding."""
+        if value is None:
+            return None
+        return quote(value, safe="~")
+
+    @staticmethod
+    def _generate_unique_nonce() -> str:
+        """Generate unique nonce with UUID, timestamp, and thread ID."""
+        return f"{uuid.uuid4()}{int(time.time() * 
1000)}{threading.current_thread().ident}"
+
+    @staticmethod
+    def _sha256_hex(data: str) -> str:
+        return hashlib.sha256(data.encode("utf-8")).hexdigest()
+
+    @staticmethod
+    def _hmac_sha256(key: bytes, data: str) -> bytes:
+        return hmac.new(key, data.encode("utf-8"), hashlib.sha256).digest()
diff --git a/paimon-python/pypaimon/api/auth/factory.py 
b/paimon-python/pypaimon/api/auth/factory.py
index 815ca12635..2ff1545597 100644
--- a/paimon-python/pypaimon/api/auth/factory.py
+++ b/paimon-python/pypaimon/api/auth/factory.py
@@ -28,6 +28,7 @@ from pypaimon.common.options.config import CatalogOptions
 class DLFAuthProviderFactory:
 
     OPENAPI_IDENTIFIER = "openapi"
+    OPENAPI_V4_IDENTIFIER = "openapi-v4"
     DEFAULT_IDENTIFIER = "default"
 
     REGION_PATTERN = r'(?:pre-)?([a-z]+-[a-z]+(?:-\d+)?)'
diff --git a/paimon-python/pypaimon/common/options/config.py 
b/paimon-python/pypaimon/common/options/config.py
index 80095404ff..1792dbd1df 100644
--- a/paimon-python/pypaimon/common/options/config.py
+++ b/paimon-python/pypaimon/common/options/config.py
@@ -126,7 +126,8 @@ class CatalogOptions:
     DLF_SIGNING_ALGORITHM = ConfigOptions.key(
         
"dlf.signing-algorithm").string_type().default_value("default").with_description(
         "DLF signing algorithm. Options: 'default' (for VPC endpoint), "
-        "'openapi' (for DlfNext/2026-01-18). "
+        "'openapi-v4' (ACS4-HMAC-SHA256, for DlfNext/2026-01-18), "
+        "'openapi' (the earlier ROA HMAC-SHA1 scheme). "
         "If not set, will be automatically selected based on endpoint host.")
     PREFIX = 
ConfigOptions.key("prefix").string_type().no_default_value().with_description("Prefix")
     HTTP_USER_AGENT_HEADER = ConfigOptions.key(
diff --git a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py 
b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
index e29842ba59..4b9ef61e95 100644
--- a/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
+++ b/paimon-python/pypaimon/tests/rest/dlf_signer_test.py
@@ -16,16 +16,18 @@
 # under the License.
 
 import unittest
+import hashlib
 import locale
 import re
 import threading
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
 
 from pypaimon.api.auth import (
     DLFAuthProvider,
     DLFAuthProviderFactory,
     DLFDefaultSigner,
     DLFOpenApiSigner,
+    DLFOpenApiV4Signer,
 )
 from pypaimon.api.token_loader import DLFToken
 from pypaimon.api.typedef import RESTAuthParameter
@@ -178,7 +180,7 @@ class DLFSignerTest(unittest.TestCase):
     def test_parse_signing_algo_from_uri(self):
         parse = DLFAuthProviderFactory.parse_signing_algo_from_uri
 
-        # dlfnext endpoints -> openapi
+        # dlfnext endpoints -> openapi (V4 is opt-in, never auto-selected)
         self.assertEqual("openapi", parse("dlfnext.cn-hangzhou.aliyuncs.com"))
         self.assertEqual("openapi", 
parse("dlfnext-vpc.cn-hangzhou.aliyuncs.com"))
         self.assertEqual("openapi", 
parse("https://dlfnext.cn-hangzhou.aliyuncs.com";))
@@ -291,6 +293,192 @@ class DLFSignerTest(unittest.TestCase):
             signer.authorization(rest_param, token, "host", None)
         self.assertIn("'sign_headers' cannot be None", str(context.exception))
 
+    def test_openapi_v4_sign_headers_with_body(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        host = "dlfnext.cn-beijing.aliyuncs.com"
+        body = '{"CategoryName":"test","CategoryType":"UNSTRUCTURED"}'
+
+        headers = signer.sign_headers(body, now, "security-token", host)
+
+        self.assertEqual("2025-04-16T03:44:46Z", headers.get("x-acs-date"))
+        self.assertEqual(host, headers.get("host"))
+        self.assertNotEqual(
+            DLFOpenApiV4Signer.EMPTY_BODY_SHA256, 
headers.get("x-acs-content-sha256"))
+        self.assertEqual("application/json", headers.get("content-type"))
+        self.assertIn("x-acs-signature-nonce", headers)
+        self.assertEqual("2026-01-18", headers.get("x-acs-version"))
+        self.assertEqual("security-token", headers.get("x-acs-security-token"))
+
+        # The V1 ROA headers belong to DLFOpenApiSigner, not to this signer
+        for absent in ("Date", "Accept", "Content-MD5",
+                       "x-acs-signature-method", "x-acs-signature-version"):
+            self.assertNotIn(absent, headers)
+
+    def test_openapi_v4_sign_headers_without_body(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+
+        headers = signer.sign_headers(None, now, None, 
"dlfnext.cn-beijing.aliyuncs.com")
+
+        self.assertEqual(
+            DLFOpenApiV4Signer.EMPTY_BODY_SHA256, 
headers.get("x-acs-content-sha256"))
+        self.assertNotIn("content-type", headers)
+
+    def test_openapi_v4_date_is_rendered_in_utc(self):
+        """x-acs-date stays UTC even when 'now' carries a non-UTC offset."""
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        beijing = datetime(2025, 4, 16, 11, 44, 46, 
tzinfo=timezone(timedelta(hours=8)))
+
+        headers = signer.sign_headers(None, beijing, None, 
"dlfnext.cn-hangzhou.aliyuncs.com")
+
+        self.assertEqual("2025-04-16T03:44:46Z", headers.get("x-acs-date"))
+
+    def test_openapi_v4_authorization_format(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", "security-token", None)
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        host = "dlfnext.cn-beijing.aliyuncs.com"
+        body = '{"CategoryName":"test"}'
+
+        headers = signer.sign_headers(body, now, token.security_token, host)
+        headers["x-acs-signature-nonce"] = "fixed-nonce"
+
+        rest_param = RESTAuthParameter("POST", "/api/test", body, {})
+        authorization = signer.authorization(rest_param, token, host, headers)
+
+        self.assertTrue(authorization.startswith("ACS4-HMAC-SHA256 
Credential="))
+        
self.assertIn("Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,",
 authorization)
+        self.assertIn("SignedHeaders=", authorization)
+
+        signature = authorization.split("Signature=")[1]
+        self.assertEqual(64, len(signature))
+        self.assertRegex(signature, r'^[0-9a-f]+$')
+
+    def test_openapi_v4_signature_is_deterministic_and_covers_query(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", None, None)
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        host = "dlfnext.cn-beijing.aliyuncs.com"
+
+        headers = signer.sign_headers(None, now, None, host)
+        headers["x-acs-signature-nonce"] = "fixed-nonce"
+
+        without_query = RESTAuthParameter("GET", "/test/path", None, {})
+        with_query = RESTAuthParameter("GET", "/test/path", None, {"k2": "v2", 
"k1": "v1"})
+
+        auth1 = signer.authorization(without_query, token, host, headers)
+        auth2 = signer.authorization(without_query, token, host, headers)
+        self.assertEqual(auth1, auth2)
+
+        self.assertNotEqual(
+            auth1, signer.authorization(with_query, token, host, headers))
+
+    def test_openapi_v4_signed_headers_scope(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", None, None)
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+        host = "dlfnext.cn-beijing.aliyuncs.com"
+        body = '{"test":"data"}'
+
+        headers = signer.sign_headers(body, now, None, host)
+        headers["x-acs-signature-nonce"] = "fixed-nonce"
+
+        rest_param = RESTAuthParameter("POST", "/test/path", body, {})
+        authorization = signer.authorization(rest_param, token, host, headers)
+
+        signed_headers = 
authorization.split("SignedHeaders=")[1].split(",Signature=")[0]
+        self.assertIn("host", signed_headers)
+        self.assertIn("content-type", signed_headers)
+        self.assertIn("x-acs-content-sha256", signed_headers)
+        self.assertIn("x-acs-date", signed_headers)
+
+    def test_openapi_v4_identifier(self):
+        self.assertEqual("openapi-v4", 
DLFOpenApiV4Signer("cn-hangzhou").identifier())
+
+    def test_openapi_v4_parameter_validation(self):
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        now = datetime.now(timezone.utc)
+        token = DLFToken("ak", "sk", "token", None)
+        rest_param = RESTAuthParameter("GET", "/", "", {})
+        headers = signer.sign_headers("", now, "", "host")
+
+        with self.assertRaises(ValueError) as context:
+            signer.sign_headers("body", None, "token", "host")
+        self.assertIn("'now' cannot be None", str(context.exception))
+
+        with self.assertRaises(ValueError) as context:
+            signer.sign_headers("body", now, "token", None)
+        self.assertIn("'host' cannot be None", str(context.exception))
+
+        with self.assertRaises(ValueError) as context:
+            signer.authorization(None, token, "host", headers)
+        self.assertIn("'rest_auth_parameter' cannot be None", 
str(context.exception))
+
+        with self.assertRaises(ValueError) as context:
+            signer.authorization(rest_param, None, "host", headers)
+        self.assertIn("'token' cannot be None", str(context.exception))
+
+        with self.assertRaises(ValueError) as context:
+            signer.authorization(rest_param, token, None, headers)
+        self.assertIn("'host' cannot be None", str(context.exception))
+
+        with self.assertRaises(ValueError) as context:
+            signer.authorization(rest_param, token, "host", None)
+        self.assertIn("'sign_headers' cannot be None", str(context.exception))
+
+    def test_auth_provider_signs_with_acs4_when_configured(self):
+        uri = "https://dlfnext.cn-hangzhou.aliyuncs.com";
+        provider = DLFAuthProvider(
+            uri=uri,
+            region="cn-hangzhou",
+            signing_algorithm=DLFOpenApiV4Signer.IDENTIFIER,
+            token=DLFToken("akId", "akSecret", None, None)
+        )
+
+        header = provider.merge_auth_header(
+            {}, RESTAuthParameter("GET", "/test/path", "", {}))
+
+        self.assertTrue(
+            header["Authorization"].startswith("ACS4-HMAC-SHA256 
Credential=akId/"))
+        self.assertIn("x-acs-date", header)
+
+    def test_openapi_v4_known_signature_for_encoded_query_values(self):
+        """RESTAuthParameter already encoded these values once; encoding them 
again would sign a
+        canonical form the gateway cannot rebuild. Keys are inserted out of 
order, so dropping the
+        sort breaks this too. The Java signer pins the same string."""
+        signer = DLFOpenApiV4Signer("cn-hangzhou")
+        token = DLFToken("TestAKId", "TestAKSecret", None, None)
+        host = "dlfnext.cn-hangzhou.aliyuncs.com"
+        now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
+
+        headers = signer.sign_headers(None, now, None, host)
+        headers["x-acs-signature-nonce"] = "fixed-nonce-for-test"
+
+        rest_param = RESTAuthParameter(
+            "GET", "/v1/prefix/functions", None,
+            {"pageToken": "a b/c", "functionNamePattern": "func%"})
+
+        self.assertEqual(
+            "ACS4-HMAC-SHA256 
Credential=TestAKId/20250416/cn-hangzhou/DlfNext/aliyun_v4_request,"
+            
"SignedHeaders=host;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;"
+            "x-acs-version,"
+            
"Signature=9812bacd92dd1152e7ea32aacfad0545835d6a18543852364f1a1ea0cba6219a",
+            signer.authorization(rest_param, token, host, headers))
+
+    def test_openapi_v4_percent_encode(self):
+        """Mirrors the Java testPercentEncode, so the two cannot drift on 
escaping."""
+        encode = DLFOpenApiV4Signer._percent_encode
+        self.assertEqual("hello%20world", encode("hello world"))
+        self.assertEqual("a%2Ab", encode("a*b"))
+        self.assertEqual("a~b", encode("a~b"))
+        self.assertEqual("a%2Fb", encode("a/b"))
+
+    def test_openapi_v4_empty_body_hash_is_sha256_of_empty_string(self):
+        """Derived independently, so a typo in the constant cannot pass."""
+        self.assertEqual(
+            hashlib.sha256(b"").hexdigest(), 
DLFOpenApiV4Signer.EMPTY_BODY_SHA256)
+
 
 if __name__ == '__main__':
     unittest.main()

Reply via email to