Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
}

Expand Down
Loading