feat(protocol): add peer ID rotation phase 1

This commit is contained in:
a1denvalu3 2026-08-04 19:58:19 +02:00
parent 094657efa0
commit 7fb3aca93a
9 changed files with 567 additions and 6 deletions

View 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()
}

View File

@ -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

View 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)
}

View File

@ -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

View File

@ -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? {

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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)
)
}
}

View 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.