diff --git a/sdk/java/core/README.md b/sdk/java/core/README.md index cb7a54c40..0e10827fe 100644 --- a/sdk/java/core/README.md +++ b/sdk/java/core/README.md @@ -4,11 +4,14 @@ For more information see our official documentation page https://docs.keeper.io/ # Change Log -## 17.0.1 -- KSM-634 - Added links2Remove parameter for files removal - -## 17.0.0 +## 17.1.0 - KSM-580 - Added new PAM fields +- KSM-581 - Add GraphSync library to KSM SDK +- KSM-582 - fix NPE use safe cast in KeeperRecordData.getField() + -KSM-586 - Add recordingIncludeKeys to data classes +- KSM-587 - Add logging option +- KSM-627 - Java SDK Add GraphSync links +- KSM-634 - Added links2Remove parameter for files removal ## 16.6.6 - KSM-560 - Improved error handling when parsing JSON diff --git a/sdk/java/core/build.gradle.kts b/sdk/java/core/build.gradle.kts index 632fb0756..ef148416d 100644 --- a/sdk/java/core/build.gradle.kts +++ b/sdk/java/core/build.gradle.kts @@ -7,7 +7,7 @@ import java.util.* group = "com.keepersecurity.secrets-manager" // During publishing, If version ends with '-SNAPSHOT' then it will be published to Maven snapshot repository -version = "17.0.0" +version = "17.1.0" plugins { `java-library` @@ -56,7 +56,7 @@ dependencies { // Use the Kotlin JUnit integration. testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.0") - testImplementation("org.bouncycastle:bc-fips:2.1.0") + testImplementation("org.bouncycastle:bc-fips:2.1.1") // testImplementation("org.bouncycastle:bcprov-jdk15on:1.70") } diff --git a/sdk/java/core/gradle.properties b/sdk/java/core/gradle.properties new file mode 100644 index 000000000..3a3b0f87e --- /dev/null +++ b/sdk/java/core/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/sdk/java/core/gradle/wrapper/gradle-wrapper.properties b/sdk/java/core/gradle/wrapper/gradle-wrapper.properties index 994b22a0b..8461cf9cb 100644 --- a/sdk/java/core/gradle/wrapper/gradle-wrapper.properties +++ b/sdk/java/core/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt index f47f59cda..d724f8ee7 100644 --- a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt +++ b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/RecordData.kt @@ -346,6 +346,7 @@ data class KeyPairs @JvmOverloads constructor( data class Host @JvmOverloads constructor( val hostName: String? = null, val port: String? = null, + val allowSupplyUser: Boolean? = null, ) @Serializable @@ -402,6 +403,7 @@ data class PamResource @JvmOverloads constructor( val controllerUid: String? = null, val folderUid: String? = null, val resourceRef: MutableList? = null, + val adminCredentialRef: String? = null, val allowedSettings: AllowedSettings? = null ) @@ -588,6 +590,7 @@ data class AppFillers @JvmOverloads constructor( data class PamRbiConnection @JvmOverloads constructor( val protocol: String? = null, val userRecords: MutableList? = null, + val recordingIncludeKeys: Boolean? = null, val allowUrlManipulation: Boolean? = null, val allowedUrlPatterns: String? = null, val allowedResourceUrlPatterns: String? = null, @@ -632,6 +635,8 @@ data class PamSettingsConnection @JvmOverloads constructor( val protocol: String? = null, val userRecords: MutableList? = null, val port: String? = null, + val allowSupplyUser: Boolean? = null, + val recordingIncludeKeys: Boolean? = null, // Common display and security settings val colorScheme: String? = null, @@ -661,6 +666,8 @@ data class PamSettingsConnection @JvmOverloads constructor( val preconnectionId: String? = null, val preconnectionBlob: String? = null, val disableAudio: Boolean? = null, + val enableWallpaper: Boolean? = null, + val enableFullWindowDrag: Boolean? = null, val sftp: SFTPConnection? = null, // Telnet specific fields diff --git a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt index 19a541e6f..b8a05fed3 100644 --- a/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt +++ b/sdk/java/core/src/main/kotlin/com/keepersecurity/secretsManager/core/SecretsManager.kt @@ -5,6 +5,7 @@ package com.keepersecurity.secretsManager.core import kotlinx.serialization.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.net.HttpURLConnection.HTTP_OK @@ -18,7 +19,7 @@ import java.util.* import java.util.concurrent.* import javax.net.ssl.* -const val KEEPER_CLIENT_VERSION = "mj17.0.1" +const val KEEPER_CLIENT_VERSION = "mj17.1.0" const val KEY_HOSTNAME = "hostname" // base url for the Secrets Manager service const val KEY_SERVER_PUBIC_KEY_ID = "serverPublicKeyId" @@ -42,7 +43,8 @@ interface KeyValueStorage { data class SecretsManagerOptions @JvmOverloads constructor( val storage: KeyValueStorage, val queryFunction: QueryFunction? = null, - val allowUnverifiedCertificate: Boolean = false + val allowUnverifiedCertificate: Boolean = false, + val loggingEnabled: Boolean = true ) { init { testSecureRandom() @@ -52,6 +54,7 @@ data class SecretsManagerOptions @JvmOverloads constructor( data class QueryOptions @JvmOverloads constructor( val recordsFilter: List = emptyList(), val foldersFilter: List = emptyList(), + val requestLinks: Boolean? = null ) data class CreateOptions @JvmOverloads constructor( @@ -83,7 +86,8 @@ private data class GetPayload( override val clientId: String, var publicKey: String? = null, var requestedRecords: List? = null, - var requestedFolders: List? = null + var requestedFolders: List? = null, + var requestLinks: Boolean? = null ): CommonPayload() @Serializable @@ -191,9 +195,145 @@ private data class SecretsManagerResponseRecord( val revision: Long, val isEditable: Boolean, val files: List?, - val innerFolderUid: String? + val innerFolderUid: String?, + val links: List? = null ) +@Serializable +data class KeeperRecordLink( + val recordUid: String, + val data: String? = null +) { + + /** + * Parse the link data as a JSON object, handling errors gracefully + */ + private fun parseJsonData(): Map? { + if (data == null) return null + + return try { + val decodedData = String(java.util.Base64.getDecoder().decode(data)) + val jsonElement = Json.parseToJsonElement(decodedData) + if (jsonElement is JsonObject) { + jsonElement.entries.associate { (key, value) -> + key to when { + value is JsonPrimitive && value.isString -> value.content + value is JsonPrimitive -> { + // Try to parse as different types + when { + value.content == "true" -> true + value.content == "false" -> false + value.content.toIntOrNull() != null -> value.content.toInt() + value.content.toLongOrNull() != null -> value.content.toLong() + else -> value.content + } + } + else -> value.toString() + } + } + } else { + System.err.println("KeeperRecordLink: Link data is not a valid JSON object") + null + } + } catch (e: Exception) { + System.err.println("KeeperRecordLink: Failed to parse link data - ${e.message}") + null + } + } + + /** + * Get a boolean value from the parsed JSON data + */ + private fun getBooleanValue(key: String): Boolean { + return parseJsonData()?.get(key) as? Boolean ?: false + } + + /** + * Get an integer value from the parsed JSON data + */ + private fun getIntValue(key: String): Int? { + return parseJsonData()?.get(key) as? Int + } + + /** + * Get a string value from the parsed JSON data + */ + private fun getStringValue(key: String): String? { + return parseJsonData()?.get(key) as? String + } + + /** + * Check if the link data indicates admin status for a user + */ + fun isAdminUser(): Boolean = getBooleanValue("is_admin") + + /** + * Check if this is a launch credential link + */ + fun isLaunchCredential(): Boolean = getBooleanValue("is_launch_credential") + + /** + * Check if rotation is allowed based on link settings + */ + fun allowsRotation(): Boolean = getBooleanValue("rotation") + + /** + * Check if connections are allowed based on link settings + */ + fun allowsConnections(): Boolean = getBooleanValue("connections") + + /** + * Check if port forwards are allowed based on link settings + */ + fun allowsPortForwards(): Boolean = getBooleanValue("portForwards") + + /** + * Check if session recording is enabled + */ + fun allowsSessionRecording(): Boolean = getBooleanValue("sessionRecording") + + /** + * Check if TypeScript recording is enabled + */ + fun allowsTypescriptRecording(): Boolean = getBooleanValue("typescriptRecording") + + /** + * Check if remote browser isolation is enabled + */ + fun allowsRemoteBrowserIsolation(): Boolean = getBooleanValue("remoteBrowserIsolation") + + /** + * Check if rotation on termination is enabled + */ + fun rotatesOnTermination(): Boolean = getBooleanValue("rotateOnTermination") + + /** + * Get the link data version (if available) + */ + fun getLinkDataVersion(): Int? = getIntValue("version") + + /** + * Get the decoded JSON data as a string (for debugging/advanced use) + */ + fun getDecodedData(): String? { + if (data == null) return null + return try { + String(java.util.Base64.getDecoder().decode(data)) + } catch (e: Exception) { + System.err.println("KeeperRecordLink: Failed to decode Base64 data - ${e.message}") + null + } + } + + /** + * Check if the link has readable JSON data (vs. encrypted/binary data) + */ + fun hasReadableData(): Boolean { + val decoded = getDecodedData() + return decoded != null && (decoded.startsWith("{") || decoded.startsWith("[")) + } +} + @Serializable private data class SecretsManagerResponseFile( val fileUid: String, @@ -258,7 +398,8 @@ data class KeeperRecord( var innerFolderUid: String? = null, val data: KeeperRecordData, val revision: Long, - val files: List? = null + val files: List? = null, + val links: List? = null ) { fun getPassword(): String? { val passwordField = data.getField() ?: return null @@ -405,7 +546,9 @@ fun getSecrets(options: SecretsManagerOptions, recordsFilter: List = emp try { fetchAndDecryptSecrets(options, queryOptions) } catch (e: Exception) { - println(e) + if (options.loggingEnabled) { + println(e) + } } } return secrets @@ -419,7 +562,9 @@ fun getSecrets2(options: SecretsManagerOptions, queryOptions: QueryOptions? = nu try { fetchAndDecryptSecrets(options, queryOptions) } catch (e: Exception) { - println(e) + if (options.loggingEnabled) { + println(e) + } } } return secrets @@ -437,7 +582,9 @@ fun tryGetNotationResults(options: SecretsManagerOptions, notation: String): Lis try { return getNotationResults(options, notation) } catch (e: Exception) { - println(e.message) + if (options.loggingEnabled) { + println(e.message) + } } return emptyList() } @@ -566,7 +713,7 @@ fun getNotationResults(options: SecretsManagerOptions, notation: String): List= 0) 1 else valuesCount - if (res.size != expectedSize) + if (res.size != expectedSize && options.loggingEnabled) println("Notation warning - extracted ${res.size} out of $valuesCount values for '$objPropertyName' property.") if (res.isNotEmpty()) result.addAll(res) @@ -757,7 +904,7 @@ private fun fetchAndDecryptSecrets( if (response.records != null) { response.records.forEach { val recordKey = decrypt(it.recordKey, appKey) - val decryptedRecord = decryptRecord(it, recordKey) + val decryptedRecord = decryptRecord(it, recordKey, options) if (decryptedRecord != null) { records.add(decryptedRecord) } @@ -768,7 +915,7 @@ private fun fetchAndDecryptSecrets( val folderKey = decrypt(folder.folderKey, appKey) folder.records!!.forEach { record -> val recordKey = decrypt(record.recordKey, folderKey) - val decryptedRecord = decryptRecord(record, recordKey) + val decryptedRecord = decryptRecord(record, recordKey, options) if (decryptedRecord != null) { decryptedRecord.folderUid = folder.folderUid decryptedRecord.folderKey = folderKey @@ -790,7 +937,7 @@ private fun fetchAndDecryptSecrets( } @ExperimentalSerializationApi -private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteArray): KeeperRecord? { +private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteArray, options: SecretsManagerOptions): KeeperRecord? { val decryptedRecord = decrypt(record.data, recordKey) val files: MutableList = mutableListOf() @@ -814,7 +961,7 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA // When SDK is behind/ahead of record/field type definitions then // strict mapping between JSON attributes and object properties // will fail on any unknown field/key - currently just log the error - // and continue without the field - nb! field will be lost on save + // and continue without the field - NB! field will be lost on save var recordData: KeeperRecordData? = null try { recordData = Json.decodeFromString(bytesToString(decryptedRecord)) @@ -845,13 +992,15 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA else -> "Unexpected error: ${e.message}" } - println(""" - Record ${record.recordUid} (type: $recordType) parsing error: - Error: $errorDetails - This may occur if the Keeper Secrets Manager (KSM) SDK version you're using is not compatible with the record's data schema. - Please ensure that you are using the latest version of the KSM SDK. If the issue persists, contact support@keepersecurity.com for assistance. - """.trimIndent()) - + if (options.loggingEnabled) { + println(""" + Record ${record.recordUid} (type: $recordType) parsing error: + Error: $errorDetails + This may occur if the Keeper Secrets Manager (KSM) SDK version you're using is not compatible with the record's data schema. + Please ensure that you are using the latest version of the KSM SDK. If the issue persists, contact support@keepersecurity.com for assistance. + """.trimIndent() + ) + } try { // Attempt to parse with non-strict JSON parser for recovery recordData = nonStrictJson.decodeFromString(bytesToString(decryptedRecord)) @@ -862,15 +1011,28 @@ private fun decryptRecord(record: SecretsManagerResponseRecord, recordKey: ByteA } else -> "Unexpected error during non-strict parsing: ${e2.message}" } - println(""" - Failed to parse record ${record.recordUid} (type: $recordType) even with non-strict parser. - Error: $secondaryError - Record will be skipped. - """.trimIndent()) + if (options.loggingEnabled) { + println(""" + Failed to parse record ${record.recordUid} (type: $recordType) even with non-strict parser. + Error: $secondaryError + Record will be skipped. + """.trimIndent() + ) + } } } - return if (recordData != null) KeeperRecord(recordKey, record.recordUid, null, null, record.innerFolderUid, recordData, record.revision, files) else null + return if (recordData != null) KeeperRecord( + recordKey, + record.recordUid, + null, + null, + record.innerFolderUid, + recordData, + record.revision, + files, + record.links + ) else null } @ExperimentalSerializationApi @@ -931,7 +1093,10 @@ private fun prepareGetPayload( payload.requestedRecords = queryOptions.recordsFilter } if (queryOptions.foldersFilter.isNotEmpty()) { - payload.requestedRecords = queryOptions.foldersFilter + payload.requestedFolders = queryOptions.foldersFilter + } + if (queryOptions.requestLinks != null) { + payload.requestLinks = queryOptions.requestLinks } } return payload