mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +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.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
|
||||
|
||||
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. */
|
||||
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
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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`() {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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