diff --git a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt index c46a114f..578be6bf 100644 --- a/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt +++ b/app/src/main/java/com/bitchat/android/model/NoiseEncrypted.kt @@ -27,8 +27,17 @@ enum class NoisePayloadType(val value: UByte) { companion object { + // #1434 prerelease iOS builds briefly emitted private files as 0x09. Keep this + // decode-only: every NoisePayload constructed by Android still encodes FILE_TRANSFER as + // its canonical 0x20 value, so the compatibility alias cannot leak into new traffic. + private val PRERELEASE_FILE_TRANSFER_RAW_VALUE = 0x09u.toUByte() + fun fromValue(value: UByte): NoisePayloadType? { - return values().find { it.value == value } + return if (value == PRERELEASE_FILE_TRANSFER_RAW_VALUE) { + FILE_TRANSFER + } else { + values().find { it.value == value } + } } } } diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt index ae838ad4..065b2f1f 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt @@ -210,6 +210,40 @@ class MessageHandlerTest { } } + @Test + fun `encrypted prerelease iOS Noise 0x09 private media is delivered`() { + runBlocking { + val file = BitchatFilePacket( + fileName = "prerelease-ios.pdf", + fileSize = 4, + mimeType = "application/pdf", + content = byteArrayOf(1, 2, 3, 4) + ) + val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!! + val ciphertext = byteArrayOf(0x41, 0x42, 0x43) + whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(prereleasePlaintext) + whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname) + whenever(delegate.getMyNickname()).thenReturn("me") + whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55)) + + val outerPacket = BitchatPacket( + version = 2u, + type = MessageType.NOISE_ENCRYPTED.value, + senderID = peerID.hexToBytes(), + recipientID = myPeerID.hexToBytes(), + timestamp = System.currentTimeMillis().toULong(), + payload = ciphertext, + signature = signature, + ttl = 7u + ) + + handler.handleNoiseEncrypted(RoutedPacket(outerPacket, peerID, "direct-link")) + + verify(delegate).decryptFromPeer(ciphertext, peerID) + verify(delegate).onMessageReceived(any()) + } + } + private fun announcePacket( ageMs: Long, ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(), diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt index 8fb1c35c..e3c944c1 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/PrivateMediaTransferPreparerTest.kt @@ -47,6 +47,18 @@ class PrivateMediaTransferPreparerTest { assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte()) } + @Test + fun `prerelease iOS Noise 0x09 decodes as file transfer but re-encodes canonical 0x20`() { + val filePayload = file(3).encode()!! + val prereleasePayload = byteArrayOf(0x09) + filePayload + + val decoded = NoisePayload.decode(prereleasePayload) + + assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type) + assertTrue(filePayload.contentEquals(decoded?.data)) + assertEquals(0x20u.toUByte(), decoded!!.encode()[0].toUByte()) + } + @Test fun `legacy mode requires consent and signing failure aborts`() { val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent) diff --git a/docs/PRIVATE_MEDIA_V1.md b/docs/PRIVATE_MEDIA_V1.md index 573a0903..2a52f722 100644 --- a/docs/PRIVATE_MEDIA_V1.md +++ b/docs/PRIVATE_MEDIA_V1.md @@ -11,7 +11,15 @@ for the recipient, and only then fragments the final outer packet. - Noise payload data: one complete encoded `BitchatFilePacket`. - Outer recipient: the target peer ID; never broadcast for private media. -Do not allocate a second Noise payload type for this format. +Prerelease iOS builds of #1434 briefly emitted the inner file type as `0x09`. +Android accepts that value on decode and immediately canonicalizes it to +`NoisePayloadType.FILE_TRANSFER`; every Android encode remains `0x20`. Do not +allocate or emit a second Noise payload type for this format. + +The decode-only `0x09` alias may be removed only after every TestFlight/internal +build that emitted it has expired and the project's minimum-supported-client +policy excludes those builds. Track that release criterion explicitly; do not +remove the alias on an arbitrary calendar date. ## Capability announcement @@ -96,6 +104,8 @@ capability and bounded streaming design. - New Android to new iOS/Android: encrypted Noise `0x20` after authenticated capability promotion. +- Prerelease iOS to new Android: encrypted Noise `0x09` is decoded, + canonicalized to `0x20`, and delivered during the migration window. - New Android to a verified older client: blocked until the user explicitly accepts one relay-visible signed raw `0x22` transfer. - Old clients receiving a new announcement: ignore TLV `0x05` and continue