diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 7788bcceb1..3c8b9617bf 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -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. diff --git a/src/main/java/com/databricks/jdbc/auth/EncryptedFileTokenCache.java b/src/main/java/com/databricks/jdbc/auth/EncryptedFileTokenCache.java index 15473dcdb4..26216bee72 100644 --- a/src/main/java/com/databricks/jdbc/auth/EncryptedFileTokenCache.java +++ b/src/main/java/com/databricks/jdbc/auth/EncryptedFileTokenCache.java @@ -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; @@ -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 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; @@ -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); @@ -88,10 +78,7 @@ 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); @@ -99,81 +86,71 @@ public Token load() { 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); } } diff --git a/src/test/java/com/databricks/jdbc/auth/EncryptedFileTokenCacheTest.java b/src/test/java/com/databricks/jdbc/auth/EncryptedFileTokenCacheTest.java index 8df2caa58c..0cea8c49e8 100644 --- a/src/test/java/com/databricks/jdbc/auth/EncryptedFileTokenCacheTest.java +++ b/src/test/java/com/databricks/jdbc/auth/EncryptedFileTokenCacheTest.java @@ -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; @@ -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"); + } }