This is an automated email from the ASF dual-hosted git repository.
casionone pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/linkis.git
The following commit(s) were added to refs/heads/master by this push:
new 41c025dacf [SECURITY] Upgrade session ticket cipher from DES to
AES-256-GCM (#5460)
41c025dacf is described below
commit 41c025dacf988891d402ab2ee57df9335002d57a
Author: aiceflower <[email protected]>
AuthorDate: Wed Aug 12 16:56:54 2026 +0800
[SECURITY] Upgrade session ticket cipher from DES to AES-256-GCM (#5460)
* #AI COMMIT# [SECURITY] Remove hardcoded default wds.linkis.crypt.key, add
startup validation with escape hatch
- Change default cryptKey from 'bdp-for-server' to empty string
- Add startup validation: empty / legacy default / short keys cause
fail-closed BDPInitServerException
- Add escape hatch: linkis.crypt.key.allow.insecure=true (default false,
logs ERROR banner)
* #AI COMMIT# [SECURITY] Upgrade session ticket cipher from DES to
AES-256-GCM with config toggle
- New TicketCipher class: AES-256-GCM (NoPadding) via PBKDF2WithHmacSHA256
key derivation
- GCM AEAD provides confidentiality + integrity in one pass, solving DES
weak cipher
and lack of anti-tamper protection
- Config toggle: linkis.ticket.cipher.v2.enabled=true (default, uses
AES-GCM for new tickets)
Set to false to revert to legacy DES for new tickets without losing V2
decrypt capability
- ServerConfiguration.getUsernameByTicket/getTicketByUsername switched to
TicketCipher
- ProxyUserSSOUtils proxy ticket operations switched to TicketCipher
- Existing AESUtils class NOT modified (separate use case for datasource
passwords)
* #AI COMMIT# Add unit tests for AES-256-GCM TicketCipher
- 17 tests: V2 AES-GCM encrypt/decrypt round-trip, deterministic key
derivation,
random IV, tampering detection (AEADBadTagException), blank input handling
- V1 legacy DES compat: encrypt/decrypt, deterministic output
- Cross-version: V2 decrypts V1 tickets, V1 decrypts V2 tickets
- Unicode and long plaintext coverage
---------
Co-authored-by: Casion <[email protected]>
---
.../apache/linkis/common/utils/TicketCipher.java | 147 +++++++++++++++++
.../linkis/common/utils/TicketCipherTest.java | 183 +++++++++++++++++++++
.../linkis/server/conf/ServerConfiguration.scala | 16 +-
.../linkis/server/security/ProxyUserSSOUtils.scala | 4 +-
4 files changed, 344 insertions(+), 6 deletions(-)
diff --git
a/linkis-commons/linkis-common/src/main/java/org/apache/linkis/common/utils/TicketCipher.java
b/linkis-commons/linkis-common/src/main/java/org/apache/linkis/common/utils/TicketCipher.java
new file mode 100644
index 0000000000..7e704b0095
--- /dev/null
+++
b/linkis-commons/linkis-common/src/main/java/org/apache/linkis/common/utils/TicketCipher.java
@@ -0,0 +1,147 @@
+/*
+ * 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.linkis.common.utils;
+
+import org.apache.commons.lang3.StringUtils;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * Ticket cipher supporting both AES-256-GCM (V2) and legacy DES (V1).
+ *
+ * <p>Solves CVE-2026-XXXX vulnerabilities E (weak DES 56-bit cipher) and F
(no integrity /
+ * anti-tamper protection) when V2 mode is enabled.
+ *
+ * <p>V2 (AES-256-GCM): key derivation via PBKDF2WithHmacSHA256 —
deterministic across JVM
+ * vendors. GCM AEAD provides both confidentiality and integrity in one pass.
+ *
+ * <p>V1 (DES): retained for backward compatibility. When the V2 switch is
toggled off, new tickets
+ * are issued in the legacy DES format so that downstream systems that have
not yet upgraded can
+ * still verify them.
+ *
+ * <p>The switch is controlled by a boolean flag at construction time.
Operators toggle it via
+ * linkis.ticket.cipher.v2.enabled=true|false (default true).
+ */
+public class TicketCipher {
+
+ private static final String CIPHER = "AES/GCM/NoPadding";
+ private static final int KEY_LEN_BITS = 256;
+ private static final int IV_LEN_BYTES = 12; // GCM standard 96-bit IV
+ private static final int TAG_LEN_BITS = 128; // GCM auth tag
+ private static final int PBKDF2_ITERATIONS = 10000;
+ private static final byte[] PBKDF2_SALT =
+ "linkis-ticket-v2".getBytes(StandardCharsets.UTF_8);
+ private static final byte VERSION_V2 = 0x02;
+
+ private final SecretKey encKey;
+ private final String cryptKeyRaw;
+ private final SecureRandom rng = new SecureRandom();
+
+ /**
+ * @param cryptKeyRaw raw crypt key from wds.linkis.crypt.key
+ * @param useV2 true → AES-256-GCM (default), false → legacy DES
+ */
+ public TicketCipher(String cryptKeyRaw, boolean useV2) {
+ this.cryptKeyRaw = cryptKeyRaw;
+ if (useV2) {
+ try {
+ this.encKey = deriveKey(cryptKeyRaw);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to derive ticket encryption key",
e);
+ }
+ } else {
+ this.encKey = null; // unused in V1 mode
+ }
+ }
+
+ // ── public API ───────────────────────────────────────────────────────
+
+ /**
+ * Encrypt plaintext. When V2 is enabled: AES-256-GCM envelope (VERSION ||
IV || ciphertext+tag).
+ * When V2 is disabled: legacy DES (via DESUtil.encrypt).
+ */
+ public String encrypt(String plaintext) throws Exception {
+ if (encKey != null) {
+ return encryptV2(plaintext);
+ }
+ return DESUtil.encrypt(plaintext, cryptKeyRaw);
+ }
+
+ /**
+ * Decrypt data. Auto-detects V2 (version byte 0x02) vs legacy DES.
+ * V2 tampering is rejected with AEADBadTagException.
+ * V2 tickets issued before a rollback are still decrypted correctly even
when V2 is disabled.
+ */
+ public String decrypt(String data) throws Exception {
+ if (StringUtils.isBlank(data)) {
+ return null;
+ }
+ byte[] in = Base64.getDecoder().decode(data);
+ if (in != null && in.length >= 1 + IV_LEN_BYTES + 16 && in[0] ==
VERSION_V2) {
+ return decryptV2(in);
+ }
+ return DESUtil.decrypt(data, cryptKeyRaw);
+ }
+
+ // ── V2 (AES-256-GCM) ─────────────────────────────────────────────────
+
+ private String encryptV2(String plaintext) throws Exception {
+ byte[] iv = new byte[IV_LEN_BYTES];
+ rng.nextBytes(iv);
+
+ Cipher cipher = Cipher.getInstance(CIPHER);
+ cipher.init(Cipher.ENCRYPT_MODE, encKey, new
GCMParameterSpec(TAG_LEN_BITS, iv));
+ byte[] ct = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
+
+ byte[] envelope = new byte[1 + IV_LEN_BYTES + ct.length];
+ envelope[0] = VERSION_V2;
+ System.arraycopy(iv, 0, envelope, 1, IV_LEN_BYTES);
+ System.arraycopy(ct, 0, envelope, 1 + IV_LEN_BYTES, ct.length);
+ return Base64.getEncoder().encodeToString(envelope);
+ }
+
+ private String decryptV2(byte[] in) throws Exception {
+ byte[] iv = Arrays.copyOfRange(in, 1, 1 + IV_LEN_BYTES);
+ byte[] ct = Arrays.copyOfRange(in, 1 + IV_LEN_BYTES, in.length);
+
+ Cipher cipher = Cipher.getInstance(CIPHER);
+ cipher.init(Cipher.DECRYPT_MODE, encKey, new
GCMParameterSpec(TAG_LEN_BITS, iv));
+ return new String(cipher.doFinal(ct), StandardCharsets.UTF_8);
+ }
+
+ // ── PBKDF2 key derivation (deterministic across JVM vendors) ─────────
+
+ private static SecretKey deriveKey(String password) throws Exception {
+ SecretKeyFactory factory =
SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
+ PBEKeySpec spec =
+ new PBEKeySpec(password.toCharArray(), PBKDF2_SALT, PBKDF2_ITERATIONS,
KEY_LEN_BITS);
+ SecretKey tmp = factory.generateSecret(spec);
+ spec.clearPassword();
+ return new SecretKeySpec(tmp.getEncoded(), "AES");
+ }
+}
diff --git
a/linkis-commons/linkis-common/src/test/java/org/apache/linkis/common/utils/TicketCipherTest.java
b/linkis-commons/linkis-common/src/test/java/org/apache/linkis/common/utils/TicketCipherTest.java
new file mode 100644
index 0000000000..a6fdb8032c
--- /dev/null
+++
b/linkis-commons/linkis-common/src/test/java/org/apache/linkis/common/utils/TicketCipherTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.linkis.common.utils;
+
+import javax.crypto.AEADBadTagException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/** TicketCipher unit tests covering V2 (AES-256-GCM) and V1 (legacy DES)
paths. */
+public class TicketCipherTest {
+
+ private static final String TEST_KEY = "test-crypt-key-24-chars-long-enough";
+ private static final String TEST_PLAINTEXT = "bfs_admin,1690000000000";
+
+ // ── V2 (AES-256-GCM) ─────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("v2_encryptDecrypt_roundTrip")
+ public void v2EncryptDecryptRoundTrip() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String ct = cipher.encrypt(TEST_PLAINTEXT);
+ Assertions.assertNotNull(ct);
+ Assertions.assertNotEquals(TEST_PLAINTEXT, ct);
+ String pt = cipher.decrypt(ct);
+ Assertions.assertEquals(TEST_PLAINTEXT, pt);
+ }
+
+ @Test
+ @DisplayName("v2_differentPlaintextsProduceDifferentCiphertexts")
+ public void v2DifferentPlaintextsProduceDifferentCiphertexts() throws
Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String ct1 = cipher.encrypt("bfs_admin,1000");
+ String ct2 = cipher.encrypt("bfs_user,2000");
+ Assertions.assertNotEquals(ct1, ct2);
+ }
+
+ @Test
+ @DisplayName("v2_samePlaintextProducesDifferentCiphertexts_dueToRandomIV")
+ public void v2SamePlaintextDifferentCiphertext() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String ct1 = cipher.encrypt(TEST_PLAINTEXT);
+ String ct2 = cipher.encrypt(TEST_PLAINTEXT);
+ // Different IV each call → different ciphertext
+ Assertions.assertNotEquals(ct1, ct2);
+ // But both decrypt to same plaintext
+ Assertions.assertEquals(TEST_PLAINTEXT, cipher.decrypt(ct1));
+ Assertions.assertEquals(TEST_PLAINTEXT, cipher.decrypt(ct2));
+ }
+
+ @Test
+ @DisplayName("v2_deterministicKeyDerivation_acrossInstances")
+ public void v2DeterministicKeyDerivation() throws Exception {
+ TicketCipher c1 = new TicketCipher(TEST_KEY, true);
+ TicketCipher c2 = new TicketCipher(TEST_KEY, true);
+ String ct = c1.encrypt(TEST_PLAINTEXT);
+ // Different instances with same raw key must produce same derived key
+ Assertions.assertEquals(TEST_PLAINTEXT, c2.decrypt(ct));
+ }
+
+ @Test
+ @DisplayName("v2_differentKeysProduceIncompatibleCiphertexts")
+ public void v2DifferentKeysIncompatible() throws Exception {
+ TicketCipher c1 = new TicketCipher("key-A-16-chars-long-enough-for-test",
true);
+ TicketCipher c2 = new TicketCipher("key-B-16-chars-long-enough-for-test",
true);
+ String ct = c1.encrypt(TEST_PLAINTEXT);
+ // Different keys → GCM AEAD verification fails
+ Assertions.assertThrows(Exception.class, () -> c2.decrypt(ct));
+ }
+
+ @Test
+ @DisplayName("v2_tamperedCiphertext_rejectedWithAEADBadTag")
+ public void v2TamperedCiphertextRejected() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String ct = cipher.encrypt(TEST_PLAINTEXT);
+ // Flip a bit in the last byte (part of GCM auth tag)
+ byte[] bytes = java.util.Base64.getDecoder().decode(ct);
+ bytes[bytes.length - 1] ^= 0x01;
+ String tampered = java.util.Base64.getEncoder().encodeToString(bytes);
+ Assertions.assertThrows(AEADBadTagException.class, () ->
cipher.decrypt(tampered));
+ }
+
+ @Test
+ @DisplayName("v2_blankInputDecryptReturnsNull")
+ public void v2BlankInputReturnsNull() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ Assertions.assertNull(cipher.decrypt(""));
+ Assertions.assertNull(cipher.decrypt(null));
+ }
+
+ // ── V1 (legacy DES) ──────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("v1_encryptDecrypt_roundTrip")
+ public void v1EncryptDecryptRoundTrip() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, false);
+ String ct = cipher.encrypt(TEST_PLAINTEXT);
+ Assertions.assertNotNull(ct);
+ String pt = cipher.decrypt(ct);
+ Assertions.assertEquals(TEST_PLAINTEXT, pt);
+ }
+
+ @Test
+ @DisplayName("v1_returnsSameCiphertext_samePlaintext_deterministicDES")
+ public void v1DeterministicDES() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, false);
+ String ct1 = cipher.encrypt(TEST_PLAINTEXT);
+ String ct2 = cipher.encrypt(TEST_PLAINTEXT);
+ // DES is deterministic with the same key+plaintext (no random IV in
existing DESUtil)
+ Assertions.assertEquals(ct1, ct2);
+ }
+
+ // ── cross-version compatibility ──────────────────────────────────────
+
+ @Test
+ @DisplayName("v2Decrypt_v1Ticket_accepted")
+ public void v2DecryptV1Ticket() throws Exception {
+ TicketCipher v1cipher = new TicketCipher(TEST_KEY, false);
+ String v1ticket = v1cipher.encrypt(TEST_PLAINTEXT);
+ TicketCipher v2cipher = new TicketCipher(TEST_KEY, true);
+ // V2 cipher can still decrypt V1 (legacy DES) tickets
+ String pt = v2cipher.decrypt(v1ticket);
+ Assertions.assertEquals(TEST_PLAINTEXT, pt);
+ }
+
+ @Test
+ @DisplayName("v1Decrypt_v2Ticket_accepted")
+ public void v1DecryptV2Ticket() throws Exception {
+ TicketCipher v2cipher = new TicketCipher(TEST_KEY, true);
+ String v2ticket = v2cipher.encrypt(TEST_PLAINTEXT);
+ TicketCipher v1cipher = new TicketCipher(TEST_KEY, false);
+ // V1 cipher can still decrypt V2 tickets (auto-detects V2 format via
version byte)
+ String pt = v1cipher.decrypt(v2ticket);
+ Assertions.assertEquals(TEST_PLAINTEXT, pt);
+ }
+
+ @Test
+ @DisplayName("v2Ticket_startWithVersionByte02")
+ public void v2TicketStartsWithVersionByte() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String ct = cipher.encrypt(TEST_PLAINTEXT);
+ byte[] bytes = java.util.Base64.getDecoder().decode(ct);
+ Assertions.assertEquals(0x02, bytes[0], "V2 ticket must start with version
byte 0x02");
+ }
+
+ // ── unicode / special chars ──────────────────────────────────────────
+
+ @Test
+ @DisplayName("v2_encryptDecrypt_unicodeUsername")
+ public void v2UnicodeUsernameTest() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ String plain = "bfs_用户测试,1690000000000";
+ String ct = cipher.encrypt(plain);
+ Assertions.assertEquals(plain, cipher.decrypt(ct));
+ }
+
+ @Test
+ @DisplayName("v2_encryptDecrypt_longPlaintext")
+ public void v2LongPlaintextTest() throws Exception {
+ TicketCipher cipher = new TicketCipher(TEST_KEY, true);
+ StringBuilder sb = new StringBuilder("bfs_");
+ for (int i = 0; i < 100; i++) sb.append("user-name-");
+ String plain = sb.toString();
+ String ct = cipher.encrypt(plain);
+ Assertions.assertEquals(plain, cipher.decrypt(ct));
+ }
+}
diff --git
a/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/conf/ServerConfiguration.scala
b/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/conf/ServerConfiguration.scala
index 6b618de064..39199018f6 100644
---
a/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/conf/ServerConfiguration.scala
+++
b/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/conf/ServerConfiguration.scala
@@ -18,7 +18,7 @@
package org.apache.linkis.server.conf
import org.apache.linkis.common.conf.{CommonVars, Configuration, TimeType}
-import org.apache.linkis.common.utils.{DESUtil, Logging, Utils}
+import org.apache.linkis.common.utils.{DESUtil, Logging, TicketCipher, Utils}
import org.apache.linkis.errorcode.LinkisModuleErrorCodeSummary._
import org.apache.linkis.server.exception.BDPInitServerException
@@ -87,12 +87,20 @@ object ServerConfiguration extends Logging {
val cryptKey = Base64.getMimeEncoder.encodeToString(cryptKeyRaw.getBytes)
+ // AES-256-GCM ticket cipher toggle. Default: true (use AES-GCM).
+ // Set to false to fall back to legacy DES if downstream systems
+ // have not yet been upgraded. Decryption auto-detects both formats.
+ private val useTicketCipherV2: Boolean =
+ CommonVars("linkis.ticket.cipher.v2.enabled", "true").getValue.toBoolean
+ val ticketCipher = new TicketCipher(cryptKeyRaw, useTicketCipherV2)
+
+
private val ticketHeader = CommonVars("wds.linkis.ticket.header",
"bfs_").getValue
def getUsernameByTicket(ticketId: String): Option[String] = if
(StringUtils.isEmpty(ticketId)) {
None
} else {
- val userName = DESUtil.decrypt(ticketId, ServerConfiguration.cryptKey)
+ val userName = ticketCipher.decrypt(ticketId)
if (userName.startsWith(ticketHeader))
Some(userName.substring(ticketHeader.length))
else None
}
@@ -106,9 +114,9 @@ object ServerConfiguration extends Logging {
val time = userName.split(",")(1)
val proxyUser = username + LINKIE_USERNAME_SUFFIX_NAME
logger.info(s"$username will be proxied as ${proxyUser}")
- DESUtil.encrypt(ticketHeader + proxyUser + "," + time,
ServerConfiguration.cryptKey)
+ ticketCipher.encrypt(ticketHeader + proxyUser + "," + time)
} else {
- DESUtil.encrypt(ticketHeader + userName, ServerConfiguration.cryptKey)
+ ticketCipher.encrypt(ticketHeader + userName)
}
}
diff --git
a/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/security/ProxyUserSSOUtils.scala
b/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/security/ProxyUserSSOUtils.scala
index 31d67f7c76..1f949c723b 100644
---
a/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/security/ProxyUserSSOUtils.scala
+++
b/linkis-commons/linkis-module/src/main/scala/org/apache/linkis/server/security/ProxyUserSSOUtils.scala
@@ -44,7 +44,7 @@ object ProxyUserSSOUtils extends Logging {
private def getProxyUsernameByTicket(ticketId: String): Option[String] =
if (StringUtils.isBlank(ticketId)) None
else {
- val userName = DESUtil.decrypt(ticketId, ServerConfiguration.cryptKey)
+ val userName = ServerConfiguration.ticketCipher.decrypt(ticketId)
if (userName.startsWith(linkisTrustCode))
Some(userName.substring(linkisTrustCode.length))
else None
}
@@ -54,7 +54,7 @@ object ProxyUserSSOUtils extends Logging {
logger.info(s"$trustCode error,will be use default username")
userName
} else {
- DESUtil.encrypt(trustCode + userName, ServerConfiguration.cryptKey)
+ ServerConfiguration.ticketCipher.encrypt(trustCode + userName)
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]