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()
