diff --git a/.pubnub.yml b/.pubnub.yml index 718a79057..cee689285 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,9 +1,9 @@ name: kotlin -version: 12.0.0 +version: 12.0.1 schema: 1 scm: github.com/pubnub/kotlin files: - - build/libs/pubnub-kotlin-12.0.0-all.jar + - build/libs/pubnub-kotlin-12.0.1-all.jar sdks: - type: library @@ -23,8 +23,8 @@ sdks: - distribution-type: library distribution-repository: maven - package-name: pubnub-kotlin-12.0.0 - location: https://repo.maven.apache.org/maven2/com/pubnub/pubnub-kotlin/12.0.0/pubnub-kotlin-12.0.0.jar + package-name: pubnub-kotlin-12.0.1 + location: https://repo.maven.apache.org/maven2/com/pubnub/pubnub-kotlin/12.0.1/pubnub-kotlin-12.0.1.jar supported-platforms: supported-operating-systems: Android: @@ -121,6 +121,13 @@ sdks: license-url: https://www.apache.org/licenses/LICENSE-2.0.txt is-required: Required changelog: + - date: 2025-11-19 + version: v12.0.1 + changes: + - type: bug + text: "Fixed upload/download encrypted file API. When file is encrypted application/octet-stream data format is enforced regardless of original file type (image/jpeg, video/mp4, text/plain) or server's suggested Content-Type from generateUploadUrl." + - type: bug + text: "Removed redundant buffering when parsing encrypted data." - date: 2025-11-10 version: v12.0.0 changes: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e25e0a77..c897cf272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## v12.0.1 +November 19 2025 + +#### Fixed +- Fixed upload/download encrypted file API. When file is encrypted application/octet-stream data format is enforced regardless of original file type (image/jpeg, video/mp4, text/plain) or server's suggested Content-Type from generateUploadUrl. +- Removed redundant buffering when parsing encrypted data. + ## v12.0.0 November 10 2025 diff --git a/README.md b/README.md index d4ffdd0d5..4fd49659d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ You will need the publish and subscribe keys to authenticate your app. Get your com.pubnub pubnub-kotlin - 12.0.0 + 12.0.1 ``` diff --git a/gradle.properties b/gradle.properties index 5fabf03be..eeaffa5db 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ RELEASE_SIGNING_ENABLED=true SONATYPE_HOST=DEFAULT SONATYPE_AUTOMATIC_RELEASE=false GROUP=com.pubnub -VERSION_NAME=12.0.0 +VERSION_NAME=12.0.1 POM_PACKAGING=jar POM_NAME=PubNub SDK diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/FilesIntegrationTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/FilesIntegrationTest.kt index 117d812a5..0c332e6d3 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/FilesIntegrationTest.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/integrationTest/kotlin/com/pubnub/api/integration/FilesIntegrationTest.kt @@ -31,6 +31,111 @@ class FilesIntegrationTest : BaseIntegrationTest() { uploadListDownloadDelete(false) } + @Test + fun legacyEncryptedFileTransfer() { + uploadListDownloadDeleteFileWithCipher(true) + } + + @Test + fun aesCbcEncryptedFileTransfer() { + uploadListDownloadDeleteFileWithCipher(false) + } + + fun uploadListDownloadDeleteFileWithCipher(withLegacyCrypto: Boolean) { + if (withLegacyCrypto) { + clientConfig = { + cryptoModule = CryptoModule.createLegacyCryptoModule("enigma") + } + } else { + clientConfig = { + cryptoModule = CryptoModule.createAesCbcCryptoModule("enigma") + } + } + + val channel: String = randomChannel() + val fileName = "logback.xml" + val message = "This is message" + val meta = "This is meta" + val customMessageType = "myCustomType" + + // Read the logback.xml file from resources + val logbackResource = this.javaClass.classLoader.getResourceAsStream("logback.xml") + ?: throw IllegalStateException("logback.xml not found in resources") + val originalContent = logbackResource.readBytes() + val originalContentString = String(originalContent, StandardCharsets.UTF_8) + + val connectedLatch = CountDownLatch(1) + val fileEventReceived = CountDownLatch(1) + pubnub.addListener( + object : SubscribeCallback() { + override fun status( + pubnub: PubNub, + status: PNStatus, + ) { + if (status.category == PNStatusCategory.PNConnectedCategory) { + connectedLatch.countDown() + } + } + + override fun file( + pubnub: PubNub, + result: PNFileEventResult, + ) { + if (result.file.name == fileName && result.customMessageType == customMessageType) { + fileEventReceived.countDown() + } + } + }, + ) + pubnub.subscribe(channels = listOf(channel)) + connectedLatch.await(10, TimeUnit.SECONDS) + + val sendResult: PNFileUploadResult? = + ByteArrayInputStream(originalContent).use { + pubnub.sendFile( + channel = channel, + fileName = fileName, + inputStream = it, + message = message, + meta = meta, + customMessageType = customMessageType + ).sync() + } + + if (sendResult == null) { + Assert.fail() + return + } + fileEventReceived.await(10, TimeUnit.SECONDS) + + val (_, _, _, data) = pubnub.listFiles(channel = channel).sync() + val fileFoundOnList = data.find { it.id == sendResult.file.id } != null + Assert.assertTrue(fileFoundOnList) + + val (_, byteStream) = + pubnub.downloadFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + + byteStream?.use { + val downloadedContent = it.readBytes() + val downloadedString = String(downloadedContent, StandardCharsets.UTF_8) + Assert.assertEquals( + "Downloaded content should match original logback.xml", + originalContentString, + downloadedString + ) + } + + pubnub.deleteFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + } + @Test fun testSendFileAndDeleteFileOnChannelEntity() { val sendFileResultReference: AtomicReference = AtomicReference() @@ -205,6 +310,126 @@ class FilesIntegrationTest : BaseIntegrationTest() { ).sync() } + @Test + fun uploadLargeEncryptedFileWithLegacyCryptoModule() { + uploadLargeEncryptedFileWithCryptoModule(withLegacyCrypto = true) + } + + @Test + fun uploadLargeEncryptedFileWithAesCbcCryptoModule() { + uploadLargeEncryptedFileWithCryptoModule(withLegacyCrypto = false) + } + + fun uploadLargeEncryptedFileWithCryptoModule(withLegacyCrypto: Boolean) { + clientConfig = { + cryptoModule = CryptoModule.createLegacyCryptoModule("enigma") + } + val channel: String = randomChannel() + val fileName = "large_file_${System.currentTimeMillis()}.bin" + + // Create a large binary file (1MB) to test encryption + val largeContent = ByteArray(1024 * 1024) { it.toByte() } + + val sendResult: PNFileUploadResult? = + ByteArrayInputStream(largeContent).use { + pubnub.sendFile( + channel = channel, + fileName = fileName, + inputStream = it, + message = "Large encrypted file test", + ).sync() + } + + if (sendResult == null) { + Assert.fail("Failed to upload large encrypted file") + return + } + + // Download and verify + val (_, byteStream) = + pubnub.downloadFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + + byteStream?.use { + val downloadedContent = it.readBytes() + Assert.assertArrayEquals( + "Downloaded encrypted content should match original", + largeContent, + downloadedContent + ) + } + + // Cleanup + pubnub.deleteFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + } + + @Test + fun uploadMultipleSizesWithEncryption() { + clientConfig = { + cryptoModule = CryptoModule.createLegacyCryptoModule("enigma") + } + val channel: String = randomChannel() + + val testSizes = listOf( + 100, // Small file + 1024, // 1KB + 10240, // 10KB + 102400, // 100KB + 524288 // 512KB + ) + + for (size in testSizes) { + val fileName = "test_${size}_${System.currentTimeMillis()}.bin" + val content = ByteArray(size) { (it % 256).toByte() } + + val sendResult: PNFileUploadResult? = + ByteArrayInputStream(content).use { + pubnub.sendFile( + channel = channel, + fileName = fileName, + inputStream = it, + message = "Test file size: $size", + ).sync() + } + + if (sendResult == null) { + Assert.fail("Failed to upload file of size $size") + return + } + + // Download and verify + val (_, byteStream) = + pubnub.downloadFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + + byteStream?.use { + val downloadedContent = it.readBytes() + Assert.assertArrayEquals( + "Downloaded content should match original for size $size", + content, + downloadedContent + ) + } + + // Cleanup + pubnub.deleteFile( + channel = channel, + fileName = fileName, + fileId = sendResult.file.id, + ).sync() + } + } + private fun readToString(inputStream: InputStream): String { Scanner(inputStream).useDelimiter("\\A").use { s -> return if (s.hasNext()) { diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/crypto/cryptor/HeaderParser.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/crypto/cryptor/HeaderParser.kt index d0610e8b7..6f71c67fe 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/crypto/cryptor/HeaderParser.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/crypto/cryptor/HeaderParser.kt @@ -36,12 +36,11 @@ internal class HeaderParser(val logConfig: LogConfig?) { ) fun parseDataWithHeader(stream: BufferedInputStream): ParseResult { - val bufferedInputStream = stream.buffered() - bufferedInputStream.mark(Int.MAX_VALUE) // TODO Can be calculated from spec + stream.mark(Int.MAX_VALUE) // TODO Can be calculated from spec val possibleInitialHeader = ByteArray(MINIMAL_SIZE_OF_CRYPTO_HEADER) - val initiallyRead = bufferedInputStream.read(possibleInitialHeader) + val initiallyRead = stream.read(possibleInitialHeader) if (!possibleInitialHeader.sliceArray(SENTINEL_STARTING_INDEX..SENTINEL_ENDING_INDEX).contentEquals(SENTINEL)) { - bufferedInputStream.reset() + stream.reset() return ParseResult.NoHeader } @@ -58,17 +57,17 @@ internal class HeaderParser(val logConfig: LogConfig?) { val cryptorData: ByteArray = if (cryptorDataSizeFirstByte == THREE_BYTES_SIZE_CRYPTOR_DATA_INDICATOR) { - val cryptorDataSizeBytes = readExactlyNBytez(bufferedInputStream, 2) + val cryptorDataSizeBytes = readExactlyNBytez(stream, 2) val cryptorDataSize = convertTwoBytesToIntBigEndian(cryptorDataSizeBytes[0], cryptorDataSizeBytes[1]) - readExactlyNBytez(bufferedInputStream, cryptorDataSize) + readExactlyNBytez(stream, cryptorDataSize) } else { if (cryptorDataSizeFirstByte == UByte.MIN_VALUE) { byteArrayOf() } else { - readExactlyNBytez(bufferedInputStream, cryptorDataSizeFirstByte.toInt()) + readExactlyNBytez(stream, cryptorDataSizeFirstByte.toInt()) } } - return ParseResult.Success(cryptorId, cryptorData, bufferedInputStream) + return ParseResult.Success(cryptorId, cryptorData, stream) } private fun readExactlyNBytez( @@ -130,9 +129,9 @@ internal class HeaderParser(val logConfig: LogConfig?) { val finalCryptorDataSize: ByteArray = if (cryptorDataSize < THREE_BYTES_SIZE_CRYPTOR_DATA_INDICATOR.toInt()) { byteArrayOf(cryptorDataSize.toByte()) // cryptorDataSize will be stored on 1 byte - } else if (cryptorDataSize < MAX_VALUE_THAT_CAN_BE_STORED_ON_TWO_BYTES) { - // cryptorDataSize will be stored on 3 byte - byteArrayOf(cryptorDataSize.toByte()) + writeNumberOnTwoBytes(cryptorDataSize) + } else if (cryptorDataSize <= MAX_VALUE_THAT_CAN_BE_STORED_ON_TWO_BYTES) { + // cryptorDataSize will be stored on 3 bytes: indicator (255) + 2 bytes for actual size + byteArrayOf(THREE_BYTES_SIZE_CRYPTOR_DATA_INDICATOR.toByte()) + writeNumberOnTwoBytes(cryptorDataSize) } else { throw PubNubException( errorMessage = "Cryptor Data Size is: $cryptorDataSize whereas max cryptor data size is: $MAX_VALUE_THAT_CAN_BE_STORED_ON_TWO_BYTES", diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/SendFileEndpoint.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/SendFileEndpoint.kt index 20949ee30..1e9af018c 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/SendFileEndpoint.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/SendFileEndpoint.kt @@ -86,6 +86,7 @@ class SendFileEndpoint internal constructor( ): ExtendedRemoteAction { val result = AtomicReference() + val isEncrypted = cryptoModule != null val content = cryptoModule?.encryptStream(InputStreamSeparator(inputStream))?.use { it.readBytes() @@ -93,7 +94,7 @@ class SendFileEndpoint internal constructor( return ComposableRemoteAction.firstDo(generateUploadUrlFactory.create(channel, fileName)) // 1. generateUrl .then { res -> result.set(res) - sendFileToS3Factory.create(fileName, content, res) // 2. upload to s3 + sendFileToS3Factory.create(fileName, content, res, isEncrypted) // 2. upload to s3 }.checkpoint().then { val details = result.get() publishFileMessageFactory.create( // 3. PublishFileMessage diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/UploadFileEndpoint.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/UploadFileEndpoint.kt index 85b894c62..d627792f3 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/UploadFileEndpoint.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/main/kotlin/com/pubnub/internal/endpoints/files/UploadFileEndpoint.kt @@ -28,6 +28,7 @@ internal class UploadFileEndpoint( private val key: FormField, private val formParams: List, private val baseUrl: String, + private val isEncrypted: Boolean = false, pubNub: PubNubImpl, ) : EndpointCore(pubNub) { private val log = LoggerManager.instance.getLogger(pubnub.logConfig, this::class.java) @@ -65,21 +66,40 @@ internal class UploadFileEndpoint( log.debug( LogMessage( message = LogMessageContent.Text( - "Initiating S3 upload - fileName: $fileName, contentSize: ${content.size} bytes, contentType: $contentType, formFieldsCount: ${formParams.size}" + "Initiating S3 upload - fileName: $fileName, contentSize: ${content.size} bytes, contentType: $contentType, isEncrypted: $isEncrypted, formFieldsCount: ${formParams.size}" ) ) ) + // Override Content-Type for encrypted files to prevent UTF-8 corruption of binary data + val modifiedFormParams = if (isEncrypted) { + formParams.map { param -> + if (param.key.equals(CONTENT_TYPE_HEADER, ignoreCase = true)) { + FormField(param.key, "application/octet-stream") + } else { + param + } + } + } else { + formParams + } + val builder = MultipartBody.Builder().setType(MultipartBody.FORM) - addFormParamsWithKeyFirst(key, formParams, builder) - val mediaType = getMediaType(contentType) + addFormParamsWithKeyFirst(key, modifiedFormParams, builder) + + // For encrypted files, always use application/octet-stream to prevent charset interpretation + val mediaType = if (isEncrypted) { + APPLICATION_OCTET_STREAM + } else { + getMediaType(modifiedFormParams.findContentType()) + } builder.addFormDataPart(FILE_PART_MULTIPART, fileName, content.toRequestBody(mediaType, 0, content.size)) log.debug( LogMessage( message = LogMessageContent.Text( - "Multipart request built - executing upload to S3 for fileName: $fileName" + "Multipart request built - executing upload to S3 for fileName: $fileName with mediaType: $mediaType" ) ) ) @@ -288,6 +308,7 @@ internal class UploadFileEndpoint( fileName: String, content: ByteArray, fileUploadRequestDetails: FileUploadRequestDetails, + isEncrypted: Boolean = false, ): UploadFileEndpoint { return UploadFileEndpoint( fileName = fileName, @@ -295,6 +316,7 @@ internal class UploadFileEndpoint( key = fileUploadRequestDetails.keyFormField, formParams = fileUploadRequestDetails.formFields, baseUrl = fileUploadRequestDetails.url, + isEncrypted = isEncrypted, pubNub = pubNub ) } diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/crypto/cryptor/HeaderParserTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/crypto/cryptor/HeaderParserTest.kt index 66429d912..b758b760b 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/crypto/cryptor/HeaderParserTest.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/crypto/cryptor/HeaderParserTest.kt @@ -116,6 +116,82 @@ class HeaderParserTest { assertEquals(PubNubError.CRYPTOR_DATA_HEADER_SIZE_TO_SMALL, exception.pubnubError) } + @Test + fun `createCryptorHeader should use 255 indicator for size 256`() { + val cryptorId = byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'R'.code.toByte(), 'V'.code.toByte()) + val cryptorData = ByteArray(256) { it.toByte() } + + val header = objectUnderTest.createCryptorHeader(cryptorId, cryptorData) + + // Header structure: PNED(4) + version(1) + cryptorId(4) + sizeField(3) + data(256) + // Total: 268 bytes + assertEquals(268, header.size, "Header size should be 268 bytes") + + // Byte 9 should be 255 (0xFF) - the indicator + assertEquals(255.toByte(), header[9], "Byte 9 should be 255 (0xFF) indicator") + + // Bytes 10-11 should be 256 in big-endian (0x01, 0x00) + assertEquals(1, header[10].toInt() and 0xFF, "Byte 10 should be 1 (high byte of 256)") + assertEquals(0, header[11].toInt() and 0xFF, "Byte 11 should be 0 (low byte of 256)") + } + + @Test + fun `createCryptorHeader should use 255 indicator for size 300`() { + val cryptorId = byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'R'.code.toByte(), 'V'.code.toByte()) + val cryptorData = ByteArray(300) { it.toByte() } + + val header = objectUnderTest.createCryptorHeader(cryptorId, cryptorData) + + assertEquals(312, header.size, "Header size should be 312 bytes") // 4+1+4+3+300 + assertEquals(255.toByte(), header[9], "Byte 9 should be 255 (0xFF) indicator") + assertEquals(1, header[10].toInt() and 0xFF, "Byte 10 should be 1 (300 >> 8 = 1)") + assertEquals(44, header[11].toInt() and 0xFF, "Byte 11 should be 44 (300 & 0xFF = 44)") + } + + @Test + fun `createCryptorHeader should use 255 indicator for size 512`() { + val cryptorId = byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'R'.code.toByte(), 'V'.code.toByte()) + val cryptorData = ByteArray(512) { it.toByte() } + + val header = objectUnderTest.createCryptorHeader(cryptorId, cryptorData) + + assertEquals(524, header.size, "Header size should be 524 bytes") // 4+1+4+3+512 + assertEquals(255.toByte(), header[9], "Byte 9 should be 255 (0xFF) indicator") + assertEquals(2, header[10].toInt() and 0xFF, "Byte 10 should be 2 (512 >> 8 = 2)") + assertEquals(0, header[11].toInt() and 0xFF, "Byte 11 should be 0 (512 & 0xFF = 0)") + } + + @Test + fun `createCryptorHeader should use 255 indicator for size 65535`() { + val cryptorId = byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'R'.code.toByte(), 'V'.code.toByte()) + val cryptorData = ByteArray(65535) { 0 } + + val header = objectUnderTest.createCryptorHeader(cryptorId, cryptorData) + + assertEquals(65547, header.size, "Header size should be 65547 bytes") // 4+1+4+3+65535 + assertEquals(255.toByte(), header[9], "Byte 9 should be 255 (0xFF) indicator") + assertEquals(255.toByte(), header[10], "Byte 10 should be 255 (65535 >> 8 = 255)") + assertEquals(255.toByte(), header[11], "Byte 11 should be 255 (65535 & 0xFF = 255)") + } + + @Test + fun `createCryptorHeader should round-trip with parseDataWithHeader for size 256`() { + val cryptorId = byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'R'.code.toByte(), 'V'.code.toByte()) + val originalData = ByteArray(256) { it.toByte() } + val encryptedPayload = ByteArray(100) { 0xFF.toByte() } + + val header = objectUnderTest.createCryptorHeader(cryptorId, originalData) + val fullData = header + encryptedPayload + + val result = objectUnderTest.parseDataWithHeader(fullData) + + assertTrue(result is ParseResult.Success, "Should parse successfully") + result as ParseResult.Success + assertTrue(cryptorId.contentEquals(result.cryptoId), "Cryptor ID should match") + assertTrue(originalData.contentEquals(result.cryptorData), "Cryptor data should match") + assertTrue(encryptedPayload.contentEquals(result.encryptedData), "Encrypted data should match") + } + private fun createByteArrayThatHas255Elements(): ByteArray { var byteArray: ByteArray = byteArrayOf() for (i in 1..255) { diff --git a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/legacy/PubNubImplTest.kt b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/legacy/PubNubImplTest.kt index fc459cb28..f841ba3e1 100644 --- a/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/legacy/PubNubImplTest.kt +++ b/pubnub-kotlin/pubnub-kotlin-impl/src/test/kotlin/com/pubnub/api/legacy/PubNubImplTest.kt @@ -56,7 +56,7 @@ class PubNubImplTest : BaseTest() { fun getVersionAndTimeStamp() { val version = PubNubImpl.SDK_VERSION val timeStamp = PubNubImpl.timestamp() - assertEquals("12.0.0", version) + assertEquals("12.0.1", version) assertTrue(timeStamp > 0) }