1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.withTimeoutOrNull5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNotNull10import org.junit.Assert.assertTrue11import org.junit.Assert.assertNull12import org.junit.Assert.fail13import org.junit.Test14import kotlin.system.measureTimeMillis1516171819202122232425262728293031323334class RelayAdoptionReceiverTest {35 private class MemoryRelaySocket(36 private val inbound: Channel<ByteArray>,37 private val outbound: Channel<ByteArray>,38 ) : RelayBinarySocket {39 override fun send(frame: ByteArray) {40 check(outbound.trySend(frame.copyOf()).isSuccess)41 }4243 override suspend fun receive(): ByteArray = inbound.receive()44 override fun close(code: Int, reason: String) {45 outbound.close()46 }4748 override fun closeAfterQueuedFrames(code: Int, reason: String) {49 outbound.close()50 }51 }5253 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {54 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)55 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)56 return MemoryRelaySocket(rightToLeft, leftToRight) to57 MemoryRelaySocket(leftToRight, rightToLeft)58 }5960 private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity(61 deviceId = "$name-id",62 deviceName = name,63 deviceType = DeviceType.ANDROID,64 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),65 signingPublicKeyHex = signingPublicKeyHex,66 wsPort = 49142,67 relayUrl = "wss://relay.example.test",68 )6970 private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {71 val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")72 val tombstones = mapOf("dead-0" to 1L)73 return entries to tombstones74 }7576 @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {77 val (sourceSocket, receiverSocket) = socketPair()78 val token = "AAECAwQFBgcICQoLDA0ODw"79 val relay = "wss://relay.example.test"80 val root = ByteArray(32) { (it + 3).toByte() }81 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")82 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")83 val sourceIdentity = identity("source", sourceKeys.publicKey)84 val receiverIdentity = identity("receiver", receiverKeys.publicKey)85 val (entries, tombstones) = smallSnapshot()86 val duressJson = "{\"duress\":true}"87 var sourceStored: PairedDevice? = null88 var receiverStored: PairedDevice? = null89 var sourceMarkedSynced = false90 var receiverAppliedEntries: Map<String, String>? = null91 var receiverAppliedTombstones: Map<String, Long>? = null92 var receiverAppliedDuress: String? = null93 var receiverMarkedSynced = false9495 val sourceJob = async {96 runRelayAdoptionSourceSession(97 sourceSocket,98 token,99 relay,100 sourceKeys.secretKey,101 sourceKeys.publicKey,102 sourceIdentity,103 object : AdoptionSourceDeps {104 override val sourceIdentity = sourceIdentity105 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)106 override suspend fun vaultId() = computeVaultId(root)107 override suspend fun license() = "PPK-test"108 override suspend fun onConsent(request: AdoptionFrame.Request) = true109 override suspend fun upsertPairedDevice(device: PairedDevice) {110 sourceStored = device111 }112 override suspend fun vaultSnapshot() = smallSnapshot()113 override suspend fun duressSlotJson() = duressJson114 override suspend fun markSynced(deviceId: String) {115 sourceMarkedSynced = true116 }117 },118 confirm = { _, _ -> true },119 )120 }121122 val received = runRelayAdoptionReceiverSession(123 receiverSocket,124 PairingInvitation(125 relay,126 token,127 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),128 ),129 object : AdoptionReceiverDeps {130 override val deviceName = "receiver"131 override val deviceType = DeviceType.ANDROID132 override fun generateDeviceId() = "session-only-request"133 override suspend fun confirmSas() = true134 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =135 computeVaultId(hexToPubkey(rootKeyHex))136 override suspend fun deriveLocalIdentity() = receiverIdentity137 override suspend fun upsertPairedDevice(device: PairedDevice) {138 receiverStored = device139 }140 override suspend fun applySnapshot(141 entries: Map<String, String>,142 tombstones: Map<String, Long>,143 duressSlot: String?,144 ) {145 receiverAppliedEntries = entries146 receiverAppliedTombstones = tombstones147 receiverAppliedDuress = duressSlot148 }149 override suspend fun markSynced(deviceId: String) {150 receiverMarkedSynced = true151 }152 override fun onError(error: String) = throw AssertionError(error)153 },154 confirm = { true },155 )156157 assertNotNull(received)158 assertNotNull(sourceJob.await())159 assertEquals(entries, receiverAppliedEntries)160 assertEquals(tombstones, receiverAppliedTombstones)161 assertEquals(duressJson, receiverAppliedDuress)162 assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)163 assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)164 assertNotNull(receiverStored)165 assertNotNull(sourceStored)166 }167168 @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {169 val (sourceSocket, receiverSocket) = socketPair()170 val token = "AAECAwQFBgcICQoLDA0ODw"171 val relay = "wss://relay.example.test"172 val root = ByteArray(32) { (it + 5).toByte() }173 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")174 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")175 val sourceIdentity = identity("source", sourceKeys.publicKey)176 val receiverIdentity = identity("receiver", receiverKeys.publicKey)177 var sourceStored: PairedDevice? = null178 var receiverStored: PairedDevice? = null179 var receiverApplied = false180 var receiverMarkedSynced = false181182 val sourceJob = async {183 runRelayAdoptionSourceSession(184 sourceSocket,185 token,186 relay,187 sourceKeys.secretKey,188 sourceKeys.publicKey,189 sourceIdentity,190 object : AdoptionSourceDeps {191 override val sourceIdentity = sourceIdentity192 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)193 override suspend fun vaultId() = computeVaultId(root)194 override suspend fun license() = "PPK-test"195 override suspend fun onConsent(request: AdoptionFrame.Request) = true196 override suspend fun upsertPairedDevice(device: PairedDevice) {197 sourceStored = device198 }199 200 override suspend fun vaultSnapshot() = null201 },202 confirm = { _, _ -> true },203 )204 }205206 val received = runRelayAdoptionReceiverSession(207 receiverSocket,208 PairingInvitation(209 relay,210 token,211 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),212 ),213 object : AdoptionReceiverDeps {214 override val deviceName = "receiver"215 override val deviceType = DeviceType.ANDROID216 override fun generateDeviceId() = "session-only-request"217 override suspend fun confirmSas() = true218 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =219 computeVaultId(hexToPubkey(rootKeyHex))220 override suspend fun deriveLocalIdentity() = receiverIdentity221 override suspend fun upsertPairedDevice(device: PairedDevice) {222 receiverStored = device223 }224 override suspend fun applySnapshot(225 entries: Map<String, String>,226 tombstones: Map<String, Long>,227 duressSlot: String?,228 ) {229 receiverApplied = true230 }231 override suspend fun markSynced(deviceId: String) {232 receiverMarkedSynced = true233 }234 override fun onError(error: String) = throw AssertionError(error)235 },236 confirm = { true },237 )238239 assertNotNull(received)240 assertNotNull(sourceJob.await())241 assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)242 assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)243 assertNotNull(receiverStored)244 assertNotNull(sourceStored)245 }246247 @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {248 val (sourceSocket, receiverSocket) = socketPair()249 val token = "AAECAwQFBgcICQoLDA0ODw"250 val relay = "wss://relay.example.test"251 val root = ByteArray(32) { (it + 7).toByte() }252 val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")253 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")254 val sourceIdentity = identity("old-source", sourceKeys.publicKey)255 val receiverIdentity = identity("receiver", receiverKeys.publicKey)256 var receiverApplied = false257258 val sourceJob = async {259 sourceSocket.receive()260 sourceSocket.send(261 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")262 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))263 .toString().toByteArray(),264 )265 val noise = noiseNKResponder(266 sourceKeys.secretKey,267 sourceKeys.publicKey,268 sourceSocket::send,269 sourceSocket::receive,270 )271 try {272 val channel = object : PairingTransport {273 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))274 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())275 override suspend fun close() = sourceSocket.close()276 }277 assertTrue(receiveFrame(channel) is AdoptionFrame.Request)278 assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)279 sendFrame(channel, AdoptionFrame.Grant(280 rootKeyHex = Rendezvous.bytesToHex(root),281 vaultId = computeVaultId(root),282 license = "PPK-test",283 source = AdoptionFrame.Source(284 deviceId = sourceIdentity.deviceId,285 deviceName = sourceIdentity.deviceName,286 deviceType = sourceIdentity.deviceType.wireValue,287 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,288 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,289 ),290 snapshotCapable = false,291 ))292 assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)293 sendFrame(channel, AdoptionFrame.Complete)294 assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)295 } finally {296 noise.close()297 }298 }299300 var received: PairedDevice? = null301 val elapsedMs = measureTimeMillis {302 received = runRelayAdoptionReceiverSession(303 receiverSocket,304 PairingInvitation(305 relay,306 token,307 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),308 ),309 object : AdoptionReceiverDeps {310 override val deviceName = "receiver"311 override val deviceType = DeviceType.ANDROID312 override fun generateDeviceId() = "session-only-request"313 override suspend fun confirmSas() = true314 override suspend fun createVaultFromRootKey(rootKeyHex: String) =315 computeVaultId(hexToPubkey(rootKeyHex))316 override suspend fun deriveLocalIdentity() = receiverIdentity317 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit318 override suspend fun applySnapshot(319 entries: Map<String, String>,320 tombstones: Map<String, Long>,321 duressSlot: String?,322 ) {323 receiverApplied = true324 }325 override fun onError(error: String) = throw AssertionError(error)326 },327 confirm = { true },328 )329 }330331 assertNotNull(received)332 sourceJob.await()333 assertFalse(receiverApplied)334 assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)335 }336337 @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {338 339 340 341 342 343 344 val (sourceSocket, receiverSocket) = socketPair()345 val token = "AAECAwQFBgcICQoLDA0ODw"346 val relay = "wss://relay.example.test"347 val root = ByteArray(32) { (it + 13).toByte() }348 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")349 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")350 val sourceSigning = deriveSyncSigningKeypair(root, "source-id")351 val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))352 val receiverIdentity = identity("receiver", receiverKeys.publicKey)353354 355 356 val manifest = buildSignedDeviceManifest(357 issuerDeviceId = "source-id",358 devices = listOf(359 ManifestDeviceEntry(360 deviceId = "peer-b",361 deviceName = "Pool B",362 deviceType = "extension",363 longTermPublicKeyHex = "11".repeat(32),364 signingPublicKeyHex = "22".repeat(32),365 relayUrl = "wss://relay.example.test",366 pairedAt = 1L,367 ),368 ManifestDeviceEntry(369 deviceId = "peer-c",370 deviceName = "Pool C",371 deviceType = "android",372 longTermPublicKeyHex = "aa".repeat(32),373 signingPublicKeyHex = "bb".repeat(32),374 relayUrl = "wss://relay.example.test",375 pairedAt = 2L,376 ),377 ),378 signingPrivateKey = sourceSigning.privateKey,379 )380381 382 383 384 val receiverRegistry = mutableMapOf<String, PairedDevice>()385 val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()386 val receiverRegistryStore = object : PairedDeviceRegistry {387 override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()388 override fun get(id: String): PairedDevice? = receiverRegistry[id]389 override fun getByPubkey(pubkeyHex: String): PairedDevice? =390 receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }391 override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }392 override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }393 override fun listRevocations(): List<DeviceRevocation> = emptyList()394 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {395 batch.removals.forEach { receiverRegistry.remove(it) }396 batch.upserts.forEach { receiverRegistry[it.deviceId] = it }397 return true398 }399 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()400 override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false401 override fun clearRevocation(deviceId: String) {}402 }403 val receiverPendingStore = object : PendingVouchedRegistry {404 override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()405 override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }406 override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }407 override fun removeBatch(ids: Set<String>): Boolean {408 receiverPending.removeAll { it.deviceId in ids }409 return true410 }411 override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true412 }413 var receiverAppliedManifest: SignedDeviceManifest? = null414415 val sourceJob = async {416 runRelayAdoptionSourceSession(417 sourceSocket,418 token,419 relay,420 sourceKeys.secretKey,421 sourceKeys.publicKey,422 sourceIdentity,423 object : AdoptionSourceDeps {424 override val sourceIdentity = sourceIdentity425 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)426 override suspend fun vaultId() = computeVaultId(root)427 override suspend fun license() = "PPK-test"428 override suspend fun onConsent(request: AdoptionFrame.Request) = true429 override suspend fun upsertPairedDevice(device: PairedDevice) {}430 override suspend fun vaultSnapshot() = smallSnapshot()431 override suspend fun duressSlotJson() = null432 override suspend fun outgoingDeviceManifest() = manifest433 override suspend fun markSynced(deviceId: String) {}434 },435 confirm = { _, _ -> true },436 )437 }438439 val received = runRelayAdoptionReceiverSession(440 receiverSocket,441 PairingInvitation(442 relay,443 token,444 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),445 ),446 object : AdoptionReceiverDeps {447 override val deviceName = "receiver"448 override val deviceType = DeviceType.ANDROID449 override fun generateDeviceId() = "session-only-request"450 override suspend fun confirmSas() = true451 override suspend fun createVaultFromRootKey(rootKeyHex: String) =452 computeVaultId(hexToPubkey(rootKeyHex))453 override suspend fun deriveLocalIdentity() = receiverIdentity454 override suspend fun upsertPairedDevice(device: PairedDevice) {455 receiverRegistry[device.deviceId] = device456 }457 override suspend fun applySnapshot(458 entries: Map<String, String>,459 tombstones: Map<String, Long>,460 duressSlot: String?,461 ) {}462 override suspend fun applyIncomingDeviceManifest(463 manifest: SignedDeviceManifest,464 authenticatedRemoteStaticPublicKey: ByteArray,465 ) {466 receiverAppliedManifest = manifest467 val adapter = VaultSyncAdapter(468 repo = createReceiverVault(root),469 ourDeviceId = "receiver-id",470 pairedDevices = receiverRegistryStore,471 pendingVouched = receiverPendingStore,472 )473 adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)474 }475 override suspend fun markSynced(deviceId: String) {}476 override fun onError(error: String) = throw AssertionError(error)477 },478 confirm = { true },479 )480481 assertNotNull(received)482 sourceJob.await()483 assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)484 485 486 val deviceIds = receiverRegistry.keys.toSet()487 assertEquals(488 "receiver should see the source + both vouched peers",489 setOf("source-id", "peer-b", "peer-c"),490 deviceIds,491 )492 493 assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)494 assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)495 496 assertTrue("pending vouched should be cleared", receiverPending.isEmpty())497 }498499 private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {500 val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())501 repo.createVault(root.joinToString("") { "%02x".format(it) })502 return repo503 }504505 private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {506 private var slotA: com.peachpasswords.vault.StoredVault? = null507 private var slotB: String? = null508 private var decoy: com.peachpasswords.vault.StoredVault? = null509 private var ver: Long = 0510 private var settings = com.peachpasswords.vault.VaultSettings()511 override fun loadSlotA() = slotA512 override fun hasVault() = slotA != null513 override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }514 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }515 override fun loadDecoySlot() = decoy516 override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }517 override fun hasDecoySlot() = decoy != null518 override fun clearDecoySlot() { decoy = null }519 override fun version() = ver520 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }521 override fun loadSettings() = settings522 override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }523 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }524 }525}526527528529530531532533534535536class RelayAdoptionInvitationPinTest {537538 539540541542543 private val HANDSHAKE_TIMEOUT_MS = 5_000L544545 private class MemoryRelaySocket(546 private val inbound: Channel<ByteArray>,547 private val outbound: Channel<ByteArray>,548 ) : RelayBinarySocket {549 override fun send(frame: ByteArray) {550 check(outbound.trySend(frame.copyOf()).isSuccess)551 }552553 override suspend fun receive(): ByteArray = inbound.receive()554 override fun close(code: Int, reason: String) {555 outbound.close()556 }557558 override fun closeAfterQueuedFrames(code: Int, reason: String) {559 outbound.close()560 }561 }562563 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {564 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)565 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)566 return MemoryRelaySocket(rightToLeft, leftToRight) to567 MemoryRelaySocket(leftToRight, rightToLeft)568 }569570 571572573574575 private fun abortingDeps(576 confirmSas: Boolean = false,577 generateDeviceId: () -> String = { error("past the pin check") },578 ) = object : AdoptionReceiverDeps {579 override val deviceName = "phone"580 override val deviceType = DeviceType.ANDROID581 override fun generateDeviceId() = generateDeviceId()582 override suspend fun confirmSas() = confirmSas583 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =584 error("past the pin check")585 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check")586 override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check")587 override fun onError(error: String) = Unit588 }589590 private fun ack(publicKey: ByteArray) = org.json.JSONObject()591 .put("type", "PEACH_HELLO_ACK")592 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey))593 .toString()594 .toByteArray()595596 @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking {597 val (phoneSocket, mitmSocket) = socketPair()598 val root = ByteArray(32) { (it + 3).toByte() }599 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")600 val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")601602 603 604 val phoneJob = async {605 try {606 runRelayAdoptionReceiverSession(607 socket = phoneSocket,608 invitation = PairingInvitation(609 "wss://relay.example.test",610 "AAECAwQFBgcICQoLDA0ODw",611 VaultStatus.POPULATED,612 Rendezvous.bytesToHex(sourceKeys.publicKey),613 ),614 receiverDeps = abortingDeps(),615 confirm = { error("past the pin check") },616 )617 "completed"618 } catch (expected: IllegalArgumentException) {619 expected.message620 }621 }622623 mitmSocket.receive() 624 mitmSocket.send(ack(attackerKeys.publicKey))625626 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }627 phoneJob.cancel()628 assertEquals("pairing_static_key_mismatch", outcome)629 }630631 @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking {632 val (phoneSocket, mitmSocket) = socketPair()633 val root = ByteArray(32) { (it + 13).toByte() }634 val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")635636 val phoneJob = async {637 try {638 runRelayAdoptionReceiverSession(639 socket = phoneSocket,640 641 invitation = PairingInvitation(642 "wss://relay.example.test",643 "AAECAwQFBgcICQoLDA0ODw",644 VaultStatus.POPULATED,645 ),646 receiverDeps = abortingDeps(),647 confirm = { error("past the pin check") },648 )649 "completed"650 } catch (expected: IllegalArgumentException) {651 expected.message652 }653 }654655 mitmSocket.receive() 656 mitmSocket.send(ack(attackerKeys.publicKey))657658 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }659 phoneJob.cancel()660 assertEquals("pairing_invitation_missing_static_key", outcome)661 }662663 664 665 @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking {666 val (phoneSocket, sourceSocket) = socketPair()667 val root = ByteArray(32) { (it + 17).toByte() }668 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")669 val reached = java.util.concurrent.atomic.AtomicBoolean(false)670671 val phoneJob = async {672 runRelayAdoptionReceiverSession(673 socket = phoneSocket,674 invitation = PairingInvitation(675 "wss://relay.example.test",676 "AAECAwQFBgcICQoLDA0ODw",677 VaultStatus.POPULATED,678 Rendezvous.bytesToHex(sourceKeys.publicKey),679 ),680 receiverDeps = abortingDeps(681 confirmSas = false,682 683 684 generateDeviceId = { reached.set(true); "session-only" },685 ),686 confirm = { true },687 )688 }689690 sourceSocket.receive() 691 sourceSocket.send(ack(sourceKeys.publicKey))692 val noise = noiseNKResponder(693 sourceKeys.secretKey,694 sourceKeys.publicKey,695 sourceSocket::send,696 sourceSocket::receive,697 )698 try {699 val channel = object : PairingTransport {700 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))701 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())702 override suspend fun close() = sourceSocket.close()703 }704 705 706 receiveFrame(channel)707 } finally {708 noise.close()709 }710711 712 713 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }714 715 716 phoneJob.cancel()717 assertNull(outcome)718 assertTrue(719 "the matching static must reach the adoption request, not be refused",720 reached.get(),721 )722 }723}