mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
feat(protocol): add peer ID rotation phase 1
This commit is contained in:
parent
094657efa0
commit
7fb3aca93a
158
app/src/main/java/com/bitchat/android/crypto/PeerIdRotation.kt
Normal file
158
app/src/main/java/com/bitchat/android/crypto/PeerIdRotation.kt
Normal file
@ -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<UInt> = 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<ByteArray>, 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()
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
import com.bitchat.android.model.RoutedPacket
|
import com.bitchat.android.model.RoutedPacket
|
||||||
|
import com.bitchat.android.model.AnnounceV2
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.channels.actor
|
import kotlinx.coroutines.channels.actor
|
||||||
@ -134,6 +135,12 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
// Handle public packet types (no address check needed)
|
// Handle public packet types (no address check needed)
|
||||||
when (messageType) {
|
when (messageType) {
|
||||||
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
|
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.MESSAGE -> handleMessage(routed)
|
||||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||||
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
|
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
|
||||||
|
|||||||
96
app/src/main/java/com/bitchat/android/model/AnnounceV2.kt
Normal file
96
app/src/main/java/com/bitchat/android/model/AnnounceV2.kt
Normal file
@ -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<Byte> {
|
||||||
|
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<Byte>.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)
|
||||||
|
}
|
||||||
@ -32,8 +32,11 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
|||||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
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. */
|
/** 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
|
* Decode the low 64 bits and ignore any future extension bytes, which
|
||||||
|
|||||||
@ -18,7 +18,8 @@ enum class MessageType(val value: UByte) {
|
|||||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||||
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
|
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 {
|
companion object {
|
||||||
fun fromValue(value: UByte): MessageType? {
|
fun fromValue(value: UByte): MessageType? {
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Byte>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,14 +62,16 @@ class IdentityAnnouncementTest {
|
|||||||
fun `local announcement send advertises private media`() {
|
fun `local announcement send advertises private media`() {
|
||||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||||
|
|
||||||
assertArrayEquals(
|
assertArrayEquals(byteArrayOf(0x05, 0x02, 0x00, 0x41), encoded.takeLast(4).toByteArray())
|
||||||
byteArrayOf(0x05, 0x02, 0x00, 0x01),
|
|
||||||
encoded.takeLast(4).toByteArray()
|
|
||||||
)
|
|
||||||
assertTrue(
|
assertTrue(
|
||||||
IdentityAnnouncement.decode(encoded)!!
|
IdentityAnnouncement.decode(encoded)!!
|
||||||
.capabilities!!
|
.capabilities!!
|
||||||
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
||||||
)
|
)
|
||||||
|
assertTrue(
|
||||||
|
IdentityAnnouncement.decode(encoded)!!
|
||||||
|
.capabilities!!
|
||||||
|
.contains(PeerCapabilities.PEER_ID_ROTATION)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
86
docs/PEER-ID-ROTATION-ANDROID.md
Normal file
86
docs/PEER-ID-ROTATION-ANDROID.md
Normal file
@ -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.
|
||||||
Loading…
x
Reference in New Issue
Block a user