This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new eb5c9f4eb0 [api] Add an opt-in DLFOpenApiV4Signer for DlfNext
endpoints (#9918)
eb5c9f4eb0 is described below
commit eb5c9f4eb0a6c1ffee64ce770334b881d6003a9c
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()