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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Bumped the Databricks SDK for Java dependency from `0.106.0` to `0.118.0`.

### Fixed
- Hardened the OAuth U2M token cache at rest (encryption key derivation and file permissions).
- Fixed `DatabaseMetaData.getURL()` exposing credentials embedded in the connection URL; secret parameters are now masked (the URL is otherwise unchanged).
- Fixed presigned URL credentials not being fully redacted in logs.
- Fixed access token exposure in DEBUG logs.
Expand Down
125 changes: 51 additions & 74 deletions src/main/java/com/databricks/jdbc/auth/EncryptedFileTokenCache.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@
import com.databricks.sdk.core.utils.SerDeUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.security.spec.KeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
Expand All @@ -31,25 +38,21 @@ public class EncryptedFileTokenCache implements TokenCache {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
private static final String SECRET_KEY_ALGORITHM = "PBKDF2WithHmacSHA256";
private static final byte[] SALT = "DatabricksJdbcTokenCache".getBytes();
private static final int ITERATION_COUNT = 65536;
private static final int KEY_LENGTH = 256;
private static final int IV_SIZE = 16; // 128 bits
private static final int SALT_SIZE = 16; // random per-file salt

private static final Set<PosixFilePermission> OWNER_ONLY =
EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);

private final Path cacheFile;
private final ObjectMapper mapper;
private final String passphrase;

/**
* Constructs a new EncryptingFileTokenCache instance.
*
* @param cacheFilePath The path where the token cache will be stored
* @param passphrase The passphrase used for encryption
*/
public EncryptedFileTokenCache(Path cacheFilePath, String passphrase) {
Objects.requireNonNull(cacheFilePath, "cacheFilePath must be defined");
Objects.requireNonNull(passphrase, "passphrase must be defined for encrypted token cache");

this.cacheFile = cacheFilePath;
this.mapper = SerDeUtils.createMapper();
this.passphrase = passphrase;
Expand All @@ -59,22 +62,9 @@ public EncryptedFileTokenCache(Path cacheFilePath, String passphrase) {
public void save(Token token) throws DatabricksException {
try {
Files.createDirectories(cacheFile.getParent());

// Serialize token to JSON
String json = mapper.writeValueAsString(token);
byte[] dataToWrite = json.getBytes(StandardCharsets.UTF_8);

// Encrypt data
dataToWrite = encrypt(dataToWrite);

Files.write(cacheFile, dataToWrite);
// Set file permissions to be readable only by the owner (equivalent to 0600)
File file = cacheFile.toFile();
file.setReadable(false, false);
file.setReadable(true, true);
file.setWritable(false, false);
file.setWritable(true, true);

byte[] dataToWrite = encrypt(json.getBytes(StandardCharsets.UTF_8));
writeOwnerOnly(cacheFile, dataToWrite);
LOGGER.debug("Successfully saved encrypted token to cache: {}", cacheFile);
} catch (Exception e) {
throw new DatabricksException("Failed to save token cache: " + e.getMessage(), e);
Expand All @@ -88,92 +78,79 @@ public Token load() {
LOGGER.debug("No token cache file found at: {}", cacheFile);
return null;
}

byte[] fileContent = Files.readAllBytes(cacheFile);

// Decrypt data
byte[] decodedContent;
try {
decodedContent = decrypt(fileContent);
} catch (Exception e) {
LOGGER.debug("Failed to decrypt token cache: {}", e.getMessage());
return null;
}

// Deserialize token from JSON
String json = new String(decodedContent, StandardCharsets.UTF_8);
Token token = mapper.readValue(json, Token.class);
LOGGER.debug("Successfully loaded encrypted token from cache: {}", cacheFile);
return token;
} catch (Exception e) {
// If there's any issue loading the token, return null
// to allow a fresh token to be obtained
LOGGER.debug("Failed to load token from cache: {}", e.getMessage());
return null;
}
}

/**
* Generates a secret key from the passphrase using PBKDF2 with HMAC-SHA256.
*
* @return A SecretKey generated from the passphrase
* @throws Exception If an error occurs generating the key
*/
private SecretKey generateSecretKey() throws Exception {
/** Writes the file owner-only, setting permissions BEFORE the content is written. */
private void writeOwnerOnly(Path path, byte[] data) throws IOException {
boolean posix = path.getFileSystem().supportedFileAttributeViews().contains("posix");
if (posix) {
try {
Files.createFile(path, PosixFilePermissions.asFileAttribute(OWNER_ONLY));
} catch (FileAlreadyExistsException e) {
Files.setPosixFilePermissions(path, OWNER_ONLY);
}
Files.write(path, data);
} else {
Files.write(path, data);
File file = path.toFile();
file.setReadable(false, false);
file.setReadable(true, true);
file.setWritable(false, false);
file.setWritable(true, true);
}
}

/** Derives the AES key from the passphrase and the given (per-file) salt. */
private SecretKey generateSecretKey(byte[] salt) throws Exception {
SecretKeyFactory factory = SecretKeyFactory.getInstance(SECRET_KEY_ALGORITHM);
KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), SALT, ITERATION_COUNT, KEY_LENGTH);
KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH);
return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), ALGORITHM);
}

/**
* Encrypts the given data using AES/CBC/PKCS5Padding encryption with a key derived from the
* passphrase. The IV is generated randomly and prepended to the encrypted data.
*
* @param data The data to encrypt
* @return The encrypted data with IV prepended
* @throws Exception If an error occurs during encryption
*/
/** Output = Base64(salt || IV || ciphertext). Salt and IV are random per save. */
private byte[] encrypt(byte[] data) throws Exception {
Cipher cipher = Cipher.getInstance(TRANSFORMATION);

// Generate a random IV
SecureRandom random = new SecureRandom();
byte[] salt = new byte[SALT_SIZE];
random.nextBytes(salt);
byte[] iv = new byte[IV_SIZE];
random.nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);

cipher.init(Cipher.ENCRYPT_MODE, generateSecretKey(), ivSpec);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, generateSecretKey(salt), new IvParameterSpec(iv));
byte[] encryptedData = cipher.doFinal(data);

// Combine IV and encrypted data
byte[] combined = new byte[iv.length + encryptedData.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(encryptedData, 0, combined, iv.length, encryptedData.length);

byte[] combined = new byte[salt.length + iv.length + encryptedData.length];
System.arraycopy(salt, 0, combined, 0, salt.length);
System.arraycopy(iv, 0, combined, salt.length, iv.length);
System.arraycopy(encryptedData, 0, combined, salt.length + iv.length, encryptedData.length);
return Base64.getEncoder().encode(combined);
}

/**
* Decrypts the given encrypted data using AES/CBC/PKCS5Padding decryption with a key derived from
* the passphrase. The IV is extracted from the beginning of the encrypted data.
*
* @param encryptedData The encrypted data with IV prepended, Base64 encoded
* @return The decrypted data
* @throws Exception If an error occurs during decryption
*/
/** Reads the per-file salt and IV back from the front of the payload, then decrypts. */
private byte[] decrypt(byte[] encryptedData) throws Exception {
byte[] decodedData = Base64.getDecoder().decode(encryptedData);

// Extract IV
byte[] iv = new byte[IV_SIZE];
byte[] actualData = new byte[decodedData.length - IV_SIZE];
System.arraycopy(decodedData, 0, iv, 0, IV_SIZE);
System.arraycopy(decodedData, IV_SIZE, actualData, 0, actualData.length);
byte[] decoded = Base64.getDecoder().decode(encryptedData);
byte[] salt = Arrays.copyOfRange(decoded, 0, SALT_SIZE);
byte[] iv = Arrays.copyOfRange(decoded, SALT_SIZE, SALT_SIZE + IV_SIZE);
byte[] actualData = Arrays.copyOfRange(decoded, SALT_SIZE + IV_SIZE, decoded.length);

Cipher cipher = Cipher.getInstance(TRANSFORMATION);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, generateSecretKey(), ivSpec);

cipher.init(Cipher.DECRYPT_MODE, generateSecretKey(salt), new IvParameterSpec(iv));
return cipher.doFinal(actualData);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

import com.databricks.sdk.core.DatabricksException;
import com.databricks.sdk.core.oauth.Token;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Base64;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
Expand Down Expand Up @@ -284,4 +288,90 @@ void should_HandleDifferentTokenTypes(String tokenType) throws DatabricksExcepti
assertNotNull(loadedToken);
assertEquals(tokenType, loadedToken.getTokenType());
}

@Test
void should_NotBeDecryptableWithHardcodedSalt() throws Exception {
EncryptedFileTokenCache cache = new EncryptedFileTokenCache(tokenCachePath, TEST_PASSPHRASE);
cache.save(
new Token(
ACCESS_TOKEN, TOKEN_TYPE, REFRESH_TOKEN, Instant.now().plus(1, ChronoUnit.HOURS)));

byte[] combined = Base64.getDecoder().decode(Files.readAllBytes(tokenCachePath));
byte[] iv = Arrays.copyOfRange(combined, 0, 16);
byte[] ct = Arrays.copyOfRange(combined, 16, combined.length);
javax.crypto.SecretKeyFactory f =
javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
javax.crypto.SecretKey key =
new javax.crypto.spec.SecretKeySpec(
f.generateSecret(
new javax.crypto.spec.PBEKeySpec(
TEST_PASSPHRASE.toCharArray(),
"DatabricksJdbcTokenCache".getBytes(),
65536,
256))
.getEncoded(),
"AES");
boolean cracked;
try {
javax.crypto.Cipher c = javax.crypto.Cipher.getInstance("AES/CBC/PKCS5Padding");
c.init(javax.crypto.Cipher.DECRYPT_MODE, key, new javax.crypto.spec.IvParameterSpec(iv));
String out = new String(c.doFinal(ct));
cracked = out.contains(REFRESH_TOKEN);
} catch (Exception e) {
cracked = false;
}
assertFalse(cracked, "Token must not be derivable from passphrase + hardcoded salt");
}

@Test
void should_UseRandomPerFileSalt() throws Exception {
Path p1 = tempDir.resolve("c1");
Path p2 = tempDir.resolve("c2");
Token t =
new Token(ACCESS_TOKEN, TOKEN_TYPE, REFRESH_TOKEN, Instant.now().plus(1, ChronoUnit.HOURS));
new EncryptedFileTokenCache(p1, TEST_PASSPHRASE).save(t);
new EncryptedFileTokenCache(p2, TEST_PASSPHRASE).save(t);
byte[] a = Arrays.copyOfRange(Base64.getDecoder().decode(Files.readAllBytes(p1)), 0, 16);
byte[] b = Arrays.copyOfRange(Base64.getDecoder().decode(Files.readAllBytes(p2)), 0, 16);
assertFalse(Arrays.equals(a, b), "Salt (first 16 bytes) must differ per file");
}

@Test
void should_LoadFromFreshInstance_SamePassphrase() throws Exception {
Token t =
new Token(ACCESS_TOKEN, TOKEN_TYPE, REFRESH_TOKEN, Instant.now().plus(1, ChronoUnit.HOURS));
new EncryptedFileTokenCache(tokenCachePath, TEST_PASSPHRASE).save(t);
Token loaded = new EncryptedFileTokenCache(tokenCachePath, TEST_PASSPHRASE).load();
assertNotNull(loaded);
assertEquals(REFRESH_TOKEN, loaded.getRefreshToken());
}

@Test
void should_WriteOwnerOnlyPermissions() throws Exception {
org.junit.jupiter.api.Assumptions.assumeTrue(
FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
EncryptedFileTokenCache cache = new EncryptedFileTokenCache(tokenCachePath, TEST_PASSPHRASE);
cache.save(
new Token(
ACCESS_TOKEN, TOKEN_TYPE, REFRESH_TOKEN, Instant.now().plus(1, ChronoUnit.HOURS)));
assertEquals(
PosixFilePermissions.fromString("rw-------"),
Files.getPosixFilePermissions(tokenCachePath));
}

@Test
void should_KeepOwnerOnlyPermissions_AfterOverwriteAndFromLoosePreexisting() throws Exception {
org.junit.jupiter.api.Assumptions.assumeTrue(
FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
Files.write(tokenCachePath, new byte[] {1, 2, 3});
Files.setPosixFilePermissions(tokenCachePath, PosixFilePermissions.fromString("rw-r--r--"));
EncryptedFileTokenCache cache = new EncryptedFileTokenCache(tokenCachePath, TEST_PASSPHRASE);
cache.save(
new Token(
ACCESS_TOKEN, TOKEN_TYPE, REFRESH_TOKEN, Instant.now().plus(1, ChronoUnit.HOURS)));
assertEquals(
PosixFilePermissions.fromString("rw-------"),
Files.getPosixFilePermissions(tokenCachePath),
"overwrite must normalize to 0600");
}
}
Loading