97 lines
3.7 KiB
Kotlin
97 lines
3.7 KiB
Kotlin
package com.hawhamburg.micr0bu
|
|||
|
|
|
||
|
|
import com.hawhamburg.micr0bu.domain.cam.OwnStationIds
|
||
|
|
import com.hawhamburg.micr0bu.domain.cam.Pseudonym
|
||
|
|
import org.junit.Assert.assertEquals
|
||
|
|
import org.junit.Assert.assertFalse
|
||
|
|
import org.junit.Assert.assertNotEquals
|
||
|
|
import org.junit.Assert.assertTrue
|
||
|
|
import org.junit.Test
|
||
|
|
import kotlin.random.Random
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pins what a transmit pseudonym is allowed to look like, and when it rotates.
|
||
|
|
*
|
||
|
|
* The address rules matter on air, not just in the app: the ESP32 writes this MAC straight into
|
||
|
|
* the 802.11 source address. A group (multicast) source address is invalid, and a random address
|
||
|
|
* without the locally-administered bit claims to belong to a real hardware vendor.
|
||
|
|
*/
|
||
|
|
class PseudonymTest {
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `rotates every ten minutes`() {
|
||
|
|
assertEquals(10 * 60_000L, Pseudonym.ROTATION_INTERVAL_MS)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `expires exactly at the rotation interval, not a millisecond before`() {
|
||
|
|
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||
|
|
assertFalse(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS - 1))
|
||
|
|
assertTrue(p.isExpired(1_000L + Pseudonym.ROTATION_INTERVAL_MS))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `a clock that moved back past the creation time forces a rotation`() {
|
||
|
|
// Otherwise a creation time now lying in the future would pin one identity until the
|
||
|
|
// clock caught up, which after a large correction could be hours.
|
||
|
|
val p = Pseudonym(stationId = 42L, mac = mac(0x02), createdAtMs = 1_000L)
|
||
|
|
assertTrue(p.isExpired(999L))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `generated addresses are locally administered unicast, whatever the random bytes`() {
|
||
|
|
repeat(500) { seed ->
|
||
|
|
val first = Pseudonym.generate(0L, Random(seed)).mac[0].toInt()
|
||
|
|
assertEquals("seed $seed: bit 1 set, bit 0 clear", 0x02, first and 0x03)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `generated station ids stay in range`() {
|
||
|
|
repeat(500) { seed ->
|
||
|
|
val id = Pseudonym.generate(0L, Random(seed)).stationId
|
||
|
|
assertTrue("seed $seed: $id", id in 1L until 0xFFFF_FFFEL)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `never generates the bench pinger's identity`() {
|
||
|
|
// Scripted so the exclusion loops actually run: the first draw of each is the bench
|
||
|
|
// value, which must be rejected in favour of the second.
|
||
|
|
val random = ScriptedRandom(
|
||
|
|
longs = ArrayDeque(listOf(OwnStationIds.BENCH_PING, 42L)),
|
||
|
|
bytes = ArrayDeque(listOf(OwnStationIds.BENCH_PING_MAC, byteArrayOf(0x13, 1, 2, 3, 4, 5))),
|
||
|
|
)
|
||
|
|
val p = Pseudonym.generate(0L, random)
|
||
|
|
assertEquals(42L, p.stationId)
|
||
|
|
assertEquals("0x13 with the group bit cleared and the local bit set", 0x12, p.mac[0].toInt() and 0xFF)
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `a rotation replaces the station id and the address together`() {
|
||
|
|
val a = Pseudonym.generate(0L, Random(1))
|
||
|
|
val b = Pseudonym.generate(Pseudonym.ROTATION_INTERVAL_MS, Random(2))
|
||
|
|
assertNotEquals(a.stationId, b.stationId)
|
||
|
|
assertFalse(a.mac.contentEquals(b.mac))
|
||
|
|
}
|
||
|
|
|
||
|
|
@Test
|
||
|
|
fun `equality compares the address bytes, not the array instance`() {
|
||
|
|
assertEquals(
|
||
|
|
Pseudonym(7L, mac(0x02), 5L),
|
||
|
|
Pseudonym(7L, mac(0x02), 5L),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
private fun mac(first: Int) = byteArrayOf(first.toByte(), 0x11, 0x22, 0x33, 0x44, 0x55)
|
||
|
|
|
||
|
|
private class ScriptedRandom(
|
||
|
|
private val longs: ArrayDeque<Long>,
|
||
|
|
private val bytes: ArrayDeque<ByteArray>,
|
||
|
|
) : Random() {
|
||
|
|
override fun nextBits(bitCount: Int): Int = error("not used by Pseudonym.generate")
|
||
|
|
override fun nextLong(from: Long, until: Long): Long = longs.removeFirst()
|
||
|
|
override fun nextBytes(size: Int): ByteArray = bytes.removeFirst().copyOf()
|
||
|
|
}
|
||
|
|
}
|