diff --git a/app/src/main/java/com/bitchat/android/crypto/PeerIdRotation.kt b/app/src/main/java/com/bitchat/android/crypto/PeerIdRotation.kt new file mode 100644 index 00000000..185eeff9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/crypto/PeerIdRotation.kt @@ -0,0 +1,158 @@ +package com.bitchat.android.crypto + +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.macs.HMac +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.SecureRandom + +/** Cross-platform primitives from docs/PEER-ID-ROTATION-ANDROID.md. */ +object PeerIdRotation { + const val ROTATION_PERIOD_SECONDS = 3_600L + const val TAG_SLOTS = 8 + const val ID_SIZE = 8 + const val TAG_SIZE = 8 + + private val ROTATION_INFO = "bitchat-peer-rotation-v1".toByteArray(Charsets.US_ASCII) + private val PEER_ID_CONTEXT = "bitchat-peer-id-v2".toByteArray(Charsets.US_ASCII) + private val RECOGNITION_INFO = "bitchat-recognition-v1".toByteArray(Charsets.US_ASCII) + private val BINDING_CONTEXT = "bitchat-peerid-binding-v1".toByteArray(Charsets.US_ASCII) + + fun epoch(unixTimeSeconds: Long): UInt { + require(unixTimeSeconds >= 0) { "Unix time must be non-negative" } + val value = unixTimeSeconds / ROTATION_PERIOD_SECONDS + require(value <= UInt.MAX_VALUE.toLong()) { "Epoch exceeds UInt32" } + return value.toUInt() + } + + fun candidateEpochs(current: UInt): List = buildList { + if (current > UInt.MIN_VALUE) add(current - 1u) + add(current) + if (current < UInt.MAX_VALUE) add(current + 1u) + } + + fun isEpochAccepted(advertised: UInt, current: UInt): Boolean = + candidateEpochs(current).contains(advertised) + + fun rotationSecret(noiseStaticPrivateKey: ByteArray): ByteArray { + require(noiseStaticPrivateKey.size == 32) { "Noise static private key must be 32 bytes" } + return hkdf(noiseStaticPrivateKey, ROTATION_INFO) + } + + fun peerId(rotationSecret: ByteArray, epoch: UInt): ByteArray { + require(rotationSecret.size == 32) { "Rotation secret must be 32 bytes" } + return hmac(rotationSecret, PEER_ID_CONTEXT + uint32be(epoch)).copyOf(ID_SIZE) + } + + fun recognitionKey(sharedSecret: ByteArray): ByteArray { + require(sharedSecret.size == 32) { "X25519 shared secret must be 32 bytes" } + return hkdf(sharedSecret, RECOGNITION_INFO) + } + + fun x25519SharedSecret(privateKey: ByteArray, publicKey: ByteArray): ByteArray { + require(privateKey.size == 32) { "X25519 private key must be 32 bytes" } + require(publicKey.size == 32) { "X25519 public key must be 32 bytes" } + return ByteArray(32).also { + X25519PrivateKeyParameters(privateKey, 0) + .generateSecret(X25519PublicKeyParameters(publicKey, 0), it, 0) + } + } + + fun recognitionTag( + recognitionKey: ByteArray, + epoch: UInt, + senderNoisePublicKey: ByteArray, + recipientNoisePublicKey: ByteArray, + announcedPeerId: ByteArray + ): ByteArray { + require(recognitionKey.size == 32) { "Recognition key must be 32 bytes" } + require(senderNoisePublicKey.size == 32) { "Sender Noise key must be 32 bytes" } + require(recipientNoisePublicKey.size == 32) { "Recipient Noise key must be 32 bytes" } + require(announcedPeerId.size == ID_SIZE) { "Announced peer ID must be 8 bytes" } + val message = uint32be(epoch) + senderNoisePublicKey + recipientNoisePublicKey + announcedPeerId + return hmac(recognitionKey, message).copyOf(TAG_SIZE) + } + + fun paddedTagBlock(tags: List, random: SecureRandom = SecureRandom()): ByteArray { + require(tags.size <= TAG_SLOTS) { "At most $TAG_SLOTS recognition tags fit in an announce" } + require(tags.all { it.size == TAG_SIZE }) { "Recognition tags must be 8 bytes" } + val slots = tags.map(ByteArray::copyOf).toMutableList() + while (slots.size < TAG_SLOTS) slots += ByteArray(TAG_SIZE).also(random::nextBytes) + slots.shuffle(random) + return slots.fold(ByteArray(0)) { block, tag -> block + tag } + } + + fun tagBlockContains(block: ByteArray, candidate: ByteArray): Boolean { + require(block.size == TAG_SLOTS * TAG_SIZE) { "Recognition tag block must be 64 bytes" } + require(candidate.size == TAG_SIZE) { "Recognition tag must be 8 bytes" } + return block.asList().chunked(TAG_SIZE).any { it.toByteArray().contentEquals(candidate) } + } + + fun bindingMessage(epoch: UInt, peerId: ByteArray, noiseStaticPublicKey: ByteArray): ByteArray { + require(peerId.size == ID_SIZE) { "Peer ID must be 8 bytes" } + require(noiseStaticPublicKey.size == 32) { "Noise static public key must be 32 bytes" } + return BINDING_CONTEXT + uint32be(epoch) + peerId + noiseStaticPublicKey + } + + fun signBinding( + signingPrivateKey: ByteArray, + epoch: UInt, + peerId: ByteArray, + noiseStaticPublicKey: ByteArray + ): ByteArray { + require(signingPrivateKey.size == 32) { "Ed25519 private key must be 32 bytes" } + val message = bindingMessage(epoch, peerId, noiseStaticPublicKey) + return Ed25519Signer().run { + init(true, Ed25519PrivateKeyParameters(signingPrivateKey, 0)) + update(message, 0, message.size) + generateSignature() + } + } + + /** Verifies only the Ed25519 signature; callers must also enforce every session binding check. */ + fun verifyBindingSignature( + signature: ByteArray, + signingPublicKey: ByteArray, + epoch: UInt, + peerId: ByteArray, + noiseStaticPublicKey: ByteArray + ): Boolean { + if (signature.size != 64 || signingPublicKey.size != 32) return false + val message = try { + bindingMessage(epoch, peerId, noiseStaticPublicKey) + } catch (_: IllegalArgumentException) { + return false + } + return Ed25519Signer().run { + init(false, Ed25519PublicKeyParameters(signingPublicKey, 0)) + update(message, 0, message.size) + verifySignature(signature) + } + } + + private fun hkdf(input: ByteArray, info: ByteArray): ByteArray = ByteArray(32).also { output -> + HKDFBytesGenerator(SHA256Digest()).apply { + init(HKDFParameters(input, byteArrayOf(), info)) + generateBytes(output, 0, output.size) + } + } + + private fun hmac(key: ByteArray, message: ByteArray): ByteArray = ByteArray(32).also { output -> + HMac(SHA256Digest()).apply { + init(KeyParameter(key)) + update(message, 0, message.size) + doFinal(output, 0) + } + } + + private fun uint32be(value: UInt): ByteArray = + ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array() +} diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 37a295e8..0fa870aa 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -4,6 +4,7 @@ import android.util.Log import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.model.RoutedPacket +import com.bitchat.android.model.AnnounceV2 import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.actor @@ -134,6 +135,12 @@ class PacketProcessor(private val myPeerID: String) { // Handle public packet types (no address check needed) when (messageType) { MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed) + MessageType.ANNOUNCE_V2 -> { + // Phase 1 is deliberately parse-only. An unsigned v2 announce must not + // establish liveness, enter the people list, or be gossip-relayed. + AnnounceV2.decode(packet.payload) + validPacket = false + } MessageType.MESSAGE -> handleMessage(routed) MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false diff --git a/app/src/main/java/com/bitchat/android/model/AnnounceV2.kt b/app/src/main/java/com/bitchat/android/model/AnnounceV2.kt new file mode 100644 index 00000000..e46a21e4 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/model/AnnounceV2.kt @@ -0,0 +1,96 @@ +package com.bitchat.android.model + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** Privacy-preserving, unsigned presence payload for message type 0x2C. */ +data class AnnounceV2( + val epoch: UInt, + val recognitionTags: ByteArray, + val capabilities: PeerCapabilities, + val bridgeGeohash: String? = null +) { + init { + require(recognitionTags.size == RECOGNITION_TAGS_SIZE) { "Recognition tags must be 64 bytes" } + require(bridgeGeohash == null || bridgeGeohash.toByteArray(Charsets.UTF_8).size <= MAX_GEOHASH_SIZE) { + "Bridge geohash must be at most 12 UTF-8 bytes" + } + } + + fun encode(): ByteArray { + val epochBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(epoch.toInt()).array() + val capabilityBytes = capabilities.encoded() + val geohashBytes = bridgeGeohash?.toByteArray(Charsets.UTF_8) + return buildList { + addTlv(EPOCH_TLV, epochBytes) + addTlv(RECOGNITION_TAGS_TLV, recognitionTags) + addTlv(CAPABILITIES_TLV, capabilityBytes) + if (geohashBytes != null) addTlv(BRIDGE_GEOHASH_TLV, geohashBytes) + }.toByteArray() + } + + companion object { + private const val EPOCH_TLV = 0x01 + private const val RECOGNITION_TAGS_TLV = 0x02 + private const val CAPABILITIES_TLV = 0x03 + private const val BRIDGE_GEOHASH_TLV = 0x04 + private const val RECOGNITION_TAGS_SIZE = 64 + private const val MAX_GEOHASH_SIZE = 12 + + fun decode(data: ByteArray): AnnounceV2? { + var offset = 0 + var epoch: UInt? = null + var tags: ByteArray? = null + var capabilities: PeerCapabilities? = null + var geohash: String? = null + var sawGeohash = false + + while (offset < data.size) { + if (offset + 2 > data.size) return null + val type = data[offset].toInt() and 0xFF + val length = data[offset + 1].toInt() and 0xFF + offset += 2 + if (offset + length > data.size) return null + val value = data.copyOfRange(offset, offset + length) + offset += length + when (type) { + EPOCH_TLV -> { + if (epoch != null || length != 4) return null + epoch = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN).int.toUInt() + } + RECOGNITION_TAGS_TLV -> { + if (tags != null || length != RECOGNITION_TAGS_SIZE) return null + tags = value + } + CAPABILITIES_TLV -> { + if (capabilities != null || length !in 1..8) return null + val decoded = PeerCapabilities.decode(value) + if (!decoded.encoded().contentEquals(value)) return null + capabilities = decoded + } + BRIDGE_GEOHASH_TLV -> { + if (sawGeohash || length > MAX_GEOHASH_SIZE) return null + sawGeohash = true + geohash = value.toString(Charsets.UTF_8) + } + else -> Unit + } + } + return AnnounceV2(epoch ?: return null, tags ?: return null, capabilities ?: return null, geohash) + } + + private fun MutableList.addTlv(type: Int, value: ByteArray) { + add(type.toByte()) + add(value.size.toByte()) + addAll(value.toList()) + } + } + + override fun equals(other: Any?): Boolean = + this === other || (other is AnnounceV2 && epoch == other.epoch && + recognitionTags.contentEquals(other.recognitionTags) && capabilities == other.capabilities && + bridgeGeohash == other.bridgeGeohash) + + override fun hashCode(): Int = 31 * (31 * (31 * epoch.hashCode() + recognitionTags.contentHashCode()) + + capabilities.hashCode()) + (bridgeGeohash?.hashCode() ?: 0) +} diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index d53f3d20..040a9899 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -32,8 +32,11 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable { /** Noise-encrypted private BitchatFilePacket using payload type 0x20. */ val PRIVATE_MEDIA = PeerCapabilities(1L shl 8) + /** Parses rotating peer-ID announces. Emission is gated by a later rollout phase. */ + val PEER_ID_ROTATION = PeerCapabilities(1L shl 14) + /** Capabilities implemented by this Android build. */ - val LOCAL_SUPPORTED = PRIVATE_MEDIA + val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or PEER_ID_ROTATION.rawValue) /** * Decode the low 64 bits and ignore any future extension bytes, which diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 9ef9e969..990661bb 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -18,7 +18,8 @@ enum class MessageType(val value: UByte) { FRAGMENT(0x20u), // Fragmentation for large packets REQUEST_SYNC(0x21u), // GCS-based sync request FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.) - VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync + VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync + ANNOUNCE_V2(0x2Cu); // Rotating-ID presence; parsed but not consumed in rollout phase 1 companion object { fun fromValue(value: UByte): MessageType? { diff --git a/app/src/test/kotlin/com/bitchat/android/crypto/PeerIdRotationTest.kt b/app/src/test/kotlin/com/bitchat/android/crypto/PeerIdRotationTest.kt new file mode 100644 index 00000000..e9353c63 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/crypto/PeerIdRotationTest.kt @@ -0,0 +1,109 @@ +package com.bitchat.android.crypto + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PeerIdRotationTest { + @Test + fun `section 7 rotation vectors match byte for byte`() { + val privateKey = ByteArray(32) { (it + 1).toByte() } + + val secret = PeerIdRotation.rotationSecret(privateKey) + val peerId = PeerIdRotation.peerId(secret, 100u) + + assertArrayEquals( + "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09".hex(), + secret + ) + assertArrayEquals("f7c08c528506a374".hex(), peerId) + } + + @Test + fun `section 7 directional recognition vectors match byte for byte`() { + val key = PeerIdRotation.recognitionKey(ByteArray(32) { 0x42 }) + val announcedId = ByteArray(8) { 0xA1.toByte() } + + val aToB = PeerIdRotation.recognitionTag( + key, 100u, ByteArray(32) { 0x0A }, ByteArray(32) { 0x0B }, announcedId + ) + val bToA = PeerIdRotation.recognitionTag( + key, 100u, ByteArray(32) { 0x0B }, ByteArray(32) { 0x0A }, announcedId + ) + + assertArrayEquals("4568f61d61d6cbfb".hex(), aToB) + assertArrayEquals("5313c7731f629959".hex(), bToA) + assertFalse(aToB.contentEquals(bToA)) + } + + @Test + fun `real X25519 peers derive the same recognition key`() { + val aPrivate = X25519PrivateKeyParameters(ByteArray(32) { (it + 1).toByte() }, 0) + val bPrivate = X25519PrivateKeyParameters(ByteArray(32) { (0x40 + it).toByte() }, 0) + val aPublic = aPrivate.generatePublicKey().encoded + val bPublic = bPrivate.generatePublicKey().encoded + + val fromA = PeerIdRotation.recognitionKey( + PeerIdRotation.x25519SharedSecret(aPrivate.encoded, bPublic) + ) + val fromB = PeerIdRotation.recognitionKey( + PeerIdRotation.x25519SharedSecret(bPrivate.encoded, aPublic) + ) + + assertArrayEquals(fromA, fromB) + } + + @Test + fun `epoch window and tag block pin matching properties`() { + assertTrue(PeerIdRotation.candidateEpochs(100u) == listOf(99u, 100u, 101u)) + assertTrue(PeerIdRotation.isEpochAccepted(99u, 100u)) + assertTrue(PeerIdRotation.isEpochAccepted(101u, 100u)) + assertFalse(PeerIdRotation.isEpochAccepted(102u, 100u)) + assertNotEquals( + PeerIdRotation.peerId(ByteArray(32) { 1 }, 100u).toList(), + PeerIdRotation.peerId(ByteArray(32) { 1 }, 101u).toList() + ) + + val tag = "4568f61d61d6cbfb".hex() + val block = PeerIdRotation.paddedTagBlock(listOf(tag), DeterministicSecureRandom()) + assertTrue(block.size == 64) + assertTrue(PeerIdRotation.tagBlockContains(block, tag)) + } + + @Test + fun `joint binding signature vector is fixed and verifies`() { + val signingPrivateKey = ByteArray(32) { (it + 1).toByte() } + val signingPublicKey = Ed25519PrivateKeyParameters(signingPrivateKey, 0).generatePublicKey().encoded + val peerId = "f7c08c528506a374".hex() + val noisePublicKey = X25519PrivateKeyParameters(signingPrivateKey, 0).generatePublicKey().encoded + + val message = PeerIdRotation.bindingMessage(100u, peerId, noisePublicKey) + val signature = PeerIdRotation.signBinding(signingPrivateKey, 100u, peerId, noisePublicKey) + + assertTrue(message.size == 69) + assertArrayEquals(JOINT_BINDING_SIGNATURE.hex(), signature) + assertTrue(PeerIdRotation.verifyBindingSignature(signature, signingPublicKey, 100u, peerId, noisePublicKey)) + assertFalse(PeerIdRotation.verifyBindingSignature(signature, signingPublicKey, 101u, peerId, noisePublicKey)) + } + + private class DeterministicSecureRandom : java.security.SecureRandom() { + private var next = 0 + override fun nextBytes(bytes: ByteArray) { + bytes.indices.forEach { bytes[it] = next++.toByte() } + } + } + + companion object { + // Seed 01..20, epoch 100, ID f7c08c528506a374, and the X25519 public key + // derived from the same 32-byte seed. Independently generated from the spec. + private const val JOINT_BINDING_SIGNATURE = + "5b1235d4fd4ff0bdef76007578dc83141e34a2014f249aeb5df3e50c7d30199" + + "b2fe1c92f7a3674a6170a1b8db3e9213aeac6fa3690fb4b5e7e4432ca69d28a0e" + + private fun String.hex(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/AnnounceV2Test.kt b/app/src/test/kotlin/com/bitchat/android/model/AnnounceV2Test.kt new file mode 100644 index 00000000..ace3a94e --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/model/AnnounceV2Test.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.model + +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnnounceV2Test { + @Test + fun `announce v2 canonical payload round trips`() { + val announce = jointVectorAnnounce() + + val encoded = announce.encode() + val decoded = AnnounceV2.decode(encoded)!! + + assertEquals(100u, decoded.epoch) + assertArrayEquals(announce.recognitionTags, decoded.recognitionTags) + assertTrue(decoded.capabilities.contains(PeerCapabilities.PEER_ID_ROTATION)) + assertEquals(null, decoded.bridgeGeohash) + } + + @Test + fun `announce v2 rejects duplicate malformed and noncanonical required fields`() { + val encoded = jointVectorAnnounce().encode() + + assertNull(AnnounceV2.decode(encoded + byteArrayOf(0x01, 0x04, 0, 0, 0, 100))) + assertNull(AnnounceV2.decode(encoded.copyOf(encoded.size - 1))) + assertNull(AnnounceV2.decode(encoded.replaceTlv(0x03, byteArrayOf(0x00, 0x40, 0x00)))) + } + + @Test + fun `joint full announce v2 packet vector is stable`() { + val packet = BitchatPacket( + version = 1u, + type = MessageType.ANNOUNCE_V2.value, + senderID = "f7c08c528506a374".hex(), + timestamp = 1_700_000_000_000uL, + payload = jointVectorAnnounce().encode(), + ttl = 3u + ) + + val encoded = BinaryProtocol.encode(packet, padding = false)!! + + assertArrayEquals(JOINT_ANNOUNCE_PACKET.hex(), encoded) + assertEquals(MessageType.ANNOUNCE_V2.value, BinaryProtocol.decode(encoded)!!.type) + } + + @Test + fun `Android outer decoder tolerates trailing bytes`() { + val packet = BitchatPacket( + type = MessageType.ANNOUNCE_V2.value, + senderID = ByteArray(8), + timestamp = 0uL, + payload = jointVectorAnnounce().encode(), + ttl = 0u + ) + val encoded = BinaryProtocol.encode(packet, padding = false)!! + + assertEquals(packet.payload.toList(), BinaryProtocol.decode(encoded + byteArrayOf(0x7F))!!.payload.toList()) + } + + private fun jointVectorAnnounce(): AnnounceV2 { + val realTags = "4568f61d61d6cbfb5313c7731f629959".hex() + val padding = ByteArray(48) { it.toByte() } + return AnnounceV2( + epoch = 100u, + recognitionTags = realTags + padding, + capabilities = PeerCapabilities.PEER_ID_ROTATION + ) + } + + private fun ByteArray.replaceTlv(type: Int, replacement: ByteArray): ByteArray { + var offset = 0 + val output = mutableListOf() + while (offset < size) { + val currentType = this[offset].toInt() and 0xFF + val length = this[offset + 1].toInt() and 0xFF + val value = copyOfRange(offset + 2, offset + 2 + length) + output += currentType.toByte() + val chosen = if (currentType == type) replacement else value + output += chosen.size.toByte() + output += chosen.toList() + offset += 2 + length + } + return output.toByteArray() + } + + companion object { + private const val JOINT_ANNOUNCE_PACKET = + "012c030000018bcfe5680000004cf7c08c528506a37401040000006402404568f61d61d6cbfb" + + "5313c7731f629959000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + + "202122232425262728292a2b2c2d2e2f03020040" + private fun String.hex(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index 78d72bee..c6389ae8 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -62,14 +62,16 @@ class IdentityAnnouncementTest { fun `local announcement send advertises private media`() { val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!! - assertArrayEquals( - byteArrayOf(0x05, 0x02, 0x00, 0x01), - encoded.takeLast(4).toByteArray() - ) + assertArrayEquals(byteArrayOf(0x05, 0x02, 0x00, 0x41), encoded.takeLast(4).toByteArray()) assertTrue( IdentityAnnouncement.decode(encoded)!! .capabilities!! .contains(PeerCapabilities.PRIVATE_MEDIA) ) + assertTrue( + IdentityAnnouncement.decode(encoded)!! + .capabilities!! + .contains(PeerCapabilities.PEER_ID_ROTATION) + ) } } diff --git a/docs/PEER-ID-ROTATION-ANDROID.md b/docs/PEER-ID-ROTATION-ANDROID.md new file mode 100644 index 00000000..9ead93fd --- /dev/null +++ b/docs/PEER-ID-ROTATION-ANDROID.md @@ -0,0 +1,86 @@ +# Peer ID Rotation: Android Phase 1 + +This document records Android's implementation and review of the cross-platform +peer-ID rotation proposal. Phase 1 is intentionally additive: Android advertises +capability bit 14, parses message type `0x2C`, and provides tested cryptographic +primitives. It still emits v1 announcements and ignores v2 announcements after +strict parsing. No rotating identity is used by the shipping mesh yet. + +## Android review + +- **O1 — one-hour epochs:** accept as the first interoperable constant. It is a + reasonable compromise for rollout, and pinning it now is better than adding + negotiation before operational data exists. Changing it later is a protocol + revision because it moves every vector. +- **O4 — unsigned v2 announcements:** acceptable only with the proposed trust + boundary. A parsed announcement must not establish liveness or enter the people + list until a recognition-tag match or a completed Noise handshake. Phase 1 + therefore parses and discards it, and does not gossip-relay it. +- **O5 — rotation during live sessions:** prefer migration at the epoch boundary, + but do not implement it until stable fingerprint-keyed session and durable-state + migration exists. Indefinite deferral would create the strongest tracking handle + for the most active peers. +- **O7 — trailing bytes:** Android's outer `BinaryProtocol` decoder currently + accepts bytes after the declared packet fields; an executable test pins this. + This does not make unilateral padding changes safe because padding participates + in signed canonical bytes. Padding changes still need capability/version gating. + +## Reproduced section 7 vectors + +The Android JVM tests reproduce the three published vectors directly from the +specification: + +```text +rotationSecret = fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09 +peerID(epoch=100) = f7c08c528506a374 +tag A->B = 4568f61d61d6cbfb +tag B->A = 5313c7731f629959 +``` + +The tests also cover a real two-sided X25519 exchange, directional tags, +consecutive epochs, the `epoch-1/current/epoch+1` window, fixed 64-byte tag +blocks, slot-independent matching, and fixed-width binding messages. + +## Proposed joint vectors + +These vectors were generated independently from the prose with Python standard +hash/HMAC primitives plus the `cryptography` Ed25519/X25519 primitives. They do +not depend on the iOS implementation. + +### Binding signature + +Inputs: + +```text +Ed25519 private seed = 0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 +epoch = 100 +peer ID = f7c08c528506a374 +Noise X25519 public key derived from private bytes 01..20 = +07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c +``` + +Canonical message: + +```text +626974636861742d7065657269642d62696e64696e672d763100000064f7c08c528506a37407a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c +``` + +Ed25519 signature: + +```text +5b1235d4fd4ff0bdef76007578dc83141e34a2014f249aeb5df3e50c7d30199b2fe1c92f7a3674a6170a1b8db3e9213aeac6fa3690fb4b5e7e4432ca69d28a0e +``` + +### Full announceV2 packet + +The packet uses version 1, type `0x2C`, TTL 3, timestamp `1700000000000`, no +flags or signature, sender `f7c08c528506a374`, epoch 100, the two published +directional tags followed by padding bytes `00..2f`, capability bit 14 encoded +minimal little-endian as `0040`, and no geohash. + +```text +012c030000018bcfe5680000004cf7c08c528506a37401040000006402404568f61d61d6cbfb5313c7731f629959000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f03020040 +``` + +The deterministic padding is only for the vector. Production tag padding must +use uniformly random bytes and tag order.