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 = localSyncKeypair(randomLocalIdentityCredentials("source-id"))82 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("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 : AdoptionSourceV2Deps {104 override suspend fun rotateSourceIdentity() = sourceIdentity105 override fun hasDeviceIdentityCollision(device: PairedDevice) = false106 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true107 override suspend fun completePendingIdentity(device: PairedDevice) = true108 override fun snapshotCapable() = true109110 override val sourceIdentity = sourceIdentity111 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)112 override suspend fun vaultId() = computeVaultId(root)113 override suspend fun license() = "PPK-test"114 override suspend fun onConsent(request: AdoptionFrame.Request) = true115 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true116 override suspend fun upsertPairedDevice(device: PairedDevice) {117 sourceStored = device118 }119 override suspend fun vaultSnapshot() = smallSnapshot()120 override suspend fun duressSlotJson() = duressJson121 override suspend fun markSynced(deviceId: String) {122 sourceMarkedSynced = true123 }124 },125 confirm = { _, _ -> true },126 )127 }128129 val received = runRelayAdoptionReceiverSession(130 receiverSocket,131 PairingInvitation(132 relay,133 token,134 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),135 ),136 object : AdoptionReceiverV2Deps {137 override fun localIdentity() = receiverIdentity138 override suspend fun rotateLocalIdentity() = localIdentity()139 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}140 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false141142 override val deviceName = "receiver"143 override val deviceType = DeviceType.ANDROID144 override fun generateDeviceId() = "session-only-request"145 override suspend fun confirmSas() = true146 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =147 computeVaultId(hexToPubkey(rootKeyHex))148 override suspend fun deriveLocalIdentity() = receiverIdentity149 override suspend fun upsertPairedDevice(device: PairedDevice) {150 receiverStored = device151 }152 override suspend fun applySnapshot(153 entries: Map<String, String>,154 tombstones: Map<String, Long>,155 duressSlot: String?,156 ) {157 receiverAppliedEntries = entries158 receiverAppliedTombstones = tombstones159 receiverAppliedDuress = duressSlot160 }161 override suspend fun markSynced(deviceId: String) {162 receiverMarkedSynced = true163 }164 override fun onError(error: String) = throw AssertionError(error)165 },166 confirm = { true },167 )168169 assertNotNull(received)170 assertNotNull(sourceJob.await())171 assertEquals(entries, receiverAppliedEntries)172 assertEquals(tombstones, receiverAppliedTombstones)173 assertEquals(duressJson, receiverAppliedDuress)174 assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)175 assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)176 assertNotNull(receiverStored)177 assertNotNull(sourceStored)178 }179180 @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {181 val (sourceSocket, receiverSocket) = socketPair()182 val token = "AAECAwQFBgcICQoLDA0ODw"183 val relay = "wss://relay.example.test"184 val root = ByteArray(32) { (it + 5).toByte() }185 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))186 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))187 val sourceIdentity = identity("source", sourceKeys.publicKey)188 val receiverIdentity = identity("receiver", receiverKeys.publicKey)189 var sourceStored: PairedDevice? = null190 var receiverStored: PairedDevice? = null191 var receiverApplied = false192 var receiverMarkedSynced = false193194 val sourceJob = async {195 runRelayAdoptionSourceSession(196 sourceSocket,197 token,198 relay,199 sourceKeys.secretKey,200 sourceKeys.publicKey,201 sourceIdentity,202 object : AdoptionSourceV2Deps {203 override suspend fun rotateSourceIdentity() = sourceIdentity204 override fun hasDeviceIdentityCollision(device: PairedDevice) = false205 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true206 override suspend fun completePendingIdentity(device: PairedDevice) = true207 override fun snapshotCapable() = true208209 override val sourceIdentity = sourceIdentity210 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)211 override suspend fun vaultId() = computeVaultId(root)212 override suspend fun license() = "PPK-test"213 override suspend fun onConsent(request: AdoptionFrame.Request) = true214 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true215 override suspend fun upsertPairedDevice(device: PairedDevice) {216 sourceStored = device217 }218 219 override suspend fun vaultSnapshot() = null220 },221 confirm = { _, _ -> true },222 )223 }224225 val received = runRelayAdoptionReceiverSession(226 receiverSocket,227 PairingInvitation(228 relay,229 token,230 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),231 ),232 object : AdoptionReceiverV2Deps {233 override fun localIdentity() = receiverIdentity234 override suspend fun rotateLocalIdentity() = localIdentity()235 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}236 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false237238 override val deviceName = "receiver"239 override val deviceType = DeviceType.ANDROID240 override fun generateDeviceId() = "session-only-request"241 override suspend fun confirmSas() = true242 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =243 computeVaultId(hexToPubkey(rootKeyHex))244 override suspend fun deriveLocalIdentity() = receiverIdentity245 override suspend fun upsertPairedDevice(device: PairedDevice) {246 receiverStored = device247 }248 override suspend fun applySnapshot(249 entries: Map<String, String>,250 tombstones: Map<String, Long>,251 duressSlot: String?,252 ) {253 receiverApplied = true254 }255 override suspend fun markSynced(deviceId: String) {256 receiverMarkedSynced = true257 }258 override fun onError(error: String) = throw AssertionError(error)259 },260 confirm = { true },261 )262263 assertNotNull(received)264 assertNotNull(sourceJob.await())265 assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)266 assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)267 assertNotNull(receiverStored)268 assertNotNull(sourceStored)269 }270271 @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {272 val (sourceSocket, receiverSocket) = socketPair()273 val token = "AAECAwQFBgcICQoLDA0ODw"274 val relay = "wss://relay.example.test"275 val root = ByteArray(32) { (it + 7).toByte() }276 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("old-source-id"))277 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))278 val sourceIdentity = identity("old-source", sourceKeys.publicKey)279 val receiverIdentity = identity("receiver", receiverKeys.publicKey)280 var receiverApplied = false281282 val sourceJob = async {283 sourceSocket.receive()284 sourceSocket.send(285 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")286 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))287 .toString().toByteArray(),288 )289 val noise = noiseNKResponder(290 sourceKeys.secretKey,291 sourceKeys.publicKey,292 sourceSocket::send,293 sourceSocket::receive,294 )295 try {296 val channel = object : PairingTransport {297 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))298 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())299 override suspend fun close() = sourceSocket.close()300 }301 assertTrue(receiveFrame(channel) is AdoptionFrame.RequestV2)302 sendFrame(channel, AdoptionFrame.ConfirmRequired)303 assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)304 sendFrame(channel, AdoptionFrame.Prepare(305 deviceId = sourceIdentity.deviceId,306 deviceName = sourceIdentity.deviceName,307 deviceType = sourceIdentity.deviceType.wireValue,308 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,309 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,310 vaultId = computeVaultId(root),311 license = "PPK-test",312 snapshotCapable = false,313 ))314 assertTrue(receiveFrame(channel) is AdoptionFrame.Ready)315 316 317 channel.send(318 AdoptJson.encodeToString(319 AdoptGrantV2.serializer(),320 AdoptGrantV2(321 type = "ADOPT_GRANT",322 rootKeyHex = Rendezvous.bytesToHex(root),323 vaultId = computeVaultId(root),324 license = "PPK-test",325 ),326 ).toByteArray(Charsets.UTF_8),327 )328 assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)329 sendFrame(channel, AdoptionFrame.Complete)330 } finally {331 noise.close()332 }333 }334335 var received: PairedDevice? = null336 val elapsedMs = measureTimeMillis {337 received = runRelayAdoptionReceiverSession(338 receiverSocket,339 PairingInvitation(340 relay,341 token,342 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),343 ),344 object : AdoptionReceiverV2Deps {345 override fun localIdentity() = receiverIdentity346 override suspend fun rotateLocalIdentity() = localIdentity()347 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}348 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false349350 override val deviceName = "receiver"351 override val deviceType = DeviceType.ANDROID352 override fun generateDeviceId() = "session-only-request"353 override suspend fun confirmSas() = true354 override suspend fun createVaultFromRootKey(rootKeyHex: String) =355 computeVaultId(hexToPubkey(rootKeyHex))356 override suspend fun deriveLocalIdentity() = receiverIdentity357 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit358 override suspend fun applySnapshot(359 entries: Map<String, String>,360 tombstones: Map<String, Long>,361 duressSlot: String?,362 ) {363 receiverApplied = true364 }365 override fun onError(error: String) = throw AssertionError(error)366 },367 confirm = { true },368 )369 }370371 assertNotNull(received)372 sourceJob.await()373 assertFalse(receiverApplied)374 assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)375 }376377 @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {378 379 380 381 382 383 384 val (sourceSocket, receiverSocket) = socketPair()385 val token = "AAECAwQFBgcICQoLDA0ODw"386 val relay = "wss://relay.example.test"387 val root = ByteArray(32) { (it + 13).toByte() }388 val sourceCredentials = randomLocalIdentityCredentials("source-id")389 val sourceKeys = localSyncKeypair(sourceCredentials)390 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))391 val sourceSigning = localSigningKeypair(sourceCredentials)392 val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))393 val receiverIdentity = identity("receiver", receiverKeys.publicKey)394395 396 397 val manifest = buildSignedDeviceManifest(398 issuerDeviceId = "source-id",399 devices = listOf(400 ManifestDeviceEntry(401 deviceId = "peer-b",402 deviceName = "Pool B",403 deviceType = "extension",404 longTermPublicKeyHex = "11".repeat(32),405 signingPublicKeyHex = "22".repeat(32),406 relayUrl = "wss://relay.example.test",407 pairedAt = 1L,408 ),409 ManifestDeviceEntry(410 deviceId = "peer-c",411 deviceName = "Pool C",412 deviceType = "android",413 longTermPublicKeyHex = "aa".repeat(32),414 signingPublicKeyHex = "bb".repeat(32),415 relayUrl = "wss://relay.example.test",416 pairedAt = 2L,417 ),418 ),419 signingPrivateKey = sourceSigning.privateKey,420 )421422 423 424 425 val receiverRegistry = mutableMapOf<String, PairedDevice>()426 val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()427 val receiverRegistryStore = object : PairedDeviceRegistry {428 override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()429 override fun get(id: String): PairedDevice? = receiverRegistry[id]430 override fun getByPubkey(pubkeyHex: String): PairedDevice? =431 receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }432 override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }433 override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }434 override fun listRevocations(): List<DeviceRevocation> = emptyList()435 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {436 batch.removals.forEach { receiverRegistry.remove(it) }437 batch.upserts.forEach { receiverRegistry[it.deviceId] = it }438 return true439 }440 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()441 override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false442 override fun clearRevocation(deviceId: String) {}443 }444 val receiverPendingStore = object : PendingVouchedRegistry {445 override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()446 override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }447 override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }448 override fun removeBatch(ids: Set<String>): Boolean {449 receiverPending.removeAll { it.deviceId in ids }450 return true451 }452 override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true453 }454 var receiverAppliedManifest: SignedDeviceManifest? = null455456 val sourceJob = async {457 runRelayAdoptionSourceSession(458 sourceSocket,459 token,460 relay,461 sourceKeys.secretKey,462 sourceKeys.publicKey,463 sourceIdentity,464 object : AdoptionSourceV2Deps {465 override suspend fun rotateSourceIdentity() = sourceIdentity466 override fun hasDeviceIdentityCollision(device: PairedDevice) = false467 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true468 override suspend fun completePendingIdentity(device: PairedDevice) = true469 override fun snapshotCapable() = true470471 override val sourceIdentity = sourceIdentity472 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)473 override suspend fun vaultId() = computeVaultId(root)474 override suspend fun license() = "PPK-test"475 override suspend fun onConsent(request: AdoptionFrame.Request) = true476 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true477 override suspend fun upsertPairedDevice(device: PairedDevice) {}478 override suspend fun vaultSnapshot() = smallSnapshot()479 override suspend fun duressSlotJson() = null480 override suspend fun outgoingDeviceManifest() = manifest481 override suspend fun markSynced(deviceId: String) {}482 },483 confirm = { _, _ -> true },484 )485 }486487 val received = runRelayAdoptionReceiverSession(488 receiverSocket,489 PairingInvitation(490 relay,491 token,492 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),493 ),494 object : AdoptionReceiverV2Deps {495 override fun localIdentity() = receiverIdentity496 override suspend fun rotateLocalIdentity() = localIdentity()497 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}498 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false499500 override val deviceName = "receiver"501 override val deviceType = DeviceType.ANDROID502 override fun generateDeviceId() = "session-only-request"503 override suspend fun confirmSas() = true504 override suspend fun createVaultFromRootKey(rootKeyHex: String) =505 computeVaultId(hexToPubkey(rootKeyHex))506 override suspend fun deriveLocalIdentity() = receiverIdentity507 override suspend fun upsertPairedDevice(device: PairedDevice) {508 receiverRegistry[device.deviceId] = device509 }510 override suspend fun applySnapshot(511 entries: Map<String, String>,512 tombstones: Map<String, Long>,513 duressSlot: String?,514 ) {}515 override suspend fun applyIncomingDeviceManifest(516 manifest: SignedDeviceManifest,517 authenticatedRemoteStaticPublicKey: ByteArray,518 ) {519 receiverAppliedManifest = manifest520 val adapter = VaultSyncAdapter(521 repo = createReceiverVault(root),522 ourDeviceId = "receiver-id",523 pairedDevices = receiverRegistryStore,524 pendingVouched = receiverPendingStore,525 localCredentials = randomLocalIdentityCredentials("receiver-id"),526 )527 adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)528 }529 override suspend fun markSynced(deviceId: String) {}530 override fun onError(error: String) = throw AssertionError(error)531 },532 confirm = { true },533 )534535 assertNotNull(received)536 sourceJob.await()537 assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)538 539 540 val deviceIds = receiverRegistry.keys.toSet()541 assertEquals(542 "receiver should see the source + both vouched peers",543 setOf("source-id", "peer-b", "peer-c"),544 deviceIds,545 )546 547 assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)548 assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)549 550 assertTrue("pending vouched should be cleared", receiverPending.isEmpty())551 }552553 private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {554 val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())555 repo.createVault(root.joinToString("") { "%02x".format(it) })556 return repo557 }558559 private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {560 private var slotA: com.peachpasswords.vault.StoredVault? = null561 private var slotB: String? = null562 private var decoy: com.peachpasswords.vault.StoredVault? = null563 private var ver: Long = 0564 private var settings = com.peachpasswords.vault.VaultSettings()565 override fun loadSlotA() = slotA566 override fun hasVault() = slotA != null567 override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }568 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }569 override fun loadDecoySlot() = decoy570 override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }571 override fun hasDecoySlot() = decoy != null572 override fun clearDecoySlot() { decoy = null }573 override fun version() = ver574 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }575 override fun loadSettings() = settings576 override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }577 override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings(); return true }578 }579}580581582583584585586587588589590class RelayAdoptionInvitationPinTest {591592 593594595596597 private val HANDSHAKE_TIMEOUT_MS = 5_000L598599 private class MemoryRelaySocket(600 private val inbound: Channel<ByteArray>,601 private val outbound: Channel<ByteArray>,602 ) : RelayBinarySocket {603 override fun send(frame: ByteArray) {604 check(outbound.trySend(frame.copyOf()).isSuccess)605 }606607 override suspend fun receive(): ByteArray = inbound.receive()608 override fun close(code: Int, reason: String) {609 outbound.close()610 }611612 override fun closeAfterQueuedFrames(code: Int, reason: String) {613 outbound.close()614 }615 }616617 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {618 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)619 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)620 return MemoryRelaySocket(rightToLeft, leftToRight) to621 MemoryRelaySocket(leftToRight, rightToLeft)622 }623624 625626627628629 private fun abortingDeps(630 confirmSas: Boolean = false,631 generateDeviceId: () -> String = { error("past the pin check") },632 ) = object : AdoptionReceiverV2Deps {633 override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")634 override suspend fun rotateLocalIdentity() = localIdentity()635 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}636 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false637638 override val deviceName = "phone"639 override val deviceType = DeviceType.ANDROID640 override fun generateDeviceId() = generateDeviceId()641 override suspend fun confirmSas() = confirmSas642 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =643 error("past the pin check")644 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check")645 override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check")646 override fun onError(error: String) = Unit647 }648649 private fun ack(publicKey: ByteArray) = org.json.JSONObject()650 .put("type", "PEACH_HELLO_ACK")651 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey))652 .toString()653 .toByteArray()654655 @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking {656 val (phoneSocket, mitmSocket) = socketPair()657 val root = ByteArray(32) { (it + 3).toByte() }658 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))659 val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id"))660661 662 663 val phoneJob = async {664 try {665 runRelayAdoptionReceiverSession(666 socket = phoneSocket,667 invitation = PairingInvitation(668 "wss://relay.example.test",669 "AAECAwQFBgcICQoLDA0ODw",670 VaultStatus.POPULATED,671 Rendezvous.bytesToHex(sourceKeys.publicKey),672 ),673 receiverDeps = abortingDeps(),674 confirm = { error("past the pin check") },675 )676 "completed"677 } catch (expected: IllegalArgumentException) {678 expected.message679 }680 }681682 mitmSocket.receive() 683 mitmSocket.send(ack(attackerKeys.publicKey))684685 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }686 phoneJob.cancel()687 assertEquals("pairing_static_key_mismatch", outcome)688 }689690 @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking {691 val (phoneSocket, mitmSocket) = socketPair()692 val root = ByteArray(32) { (it + 13).toByte() }693 val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id"))694695 val phoneJob = async {696 try {697 runRelayAdoptionReceiverSession(698 socket = phoneSocket,699 700 invitation = PairingInvitation(701 "wss://relay.example.test",702 "AAECAwQFBgcICQoLDA0ODw",703 VaultStatus.POPULATED,704 ),705 receiverDeps = abortingDeps(),706 confirm = { error("past the pin check") },707 )708 "completed"709 } catch (expected: IllegalArgumentException) {710 expected.message711 }712 }713714 mitmSocket.receive() 715 mitmSocket.send(ack(attackerKeys.publicKey))716717 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }718 phoneJob.cancel()719 assertEquals("pairing_invitation_missing_static_key", outcome)720 }721722 723 724 @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking {725 val (phoneSocket, sourceSocket) = socketPair()726 val root = ByteArray(32) { (it + 17).toByte() }727 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))728 val reached = java.util.concurrent.atomic.AtomicBoolean(false)729730 val phoneJob = async {731 runRelayAdoptionReceiverSession(732 socket = phoneSocket,733 invitation = PairingInvitation(734 "wss://relay.example.test",735 "AAECAwQFBgcICQoLDA0ODw",736 VaultStatus.POPULATED,737 Rendezvous.bytesToHex(sourceKeys.publicKey),738 ),739 receiverDeps = abortingDeps(740 confirmSas = false,741 742 743 generateDeviceId = { reached.set(true); "session-only" },744 ),745 confirm = { true },746 )747 }748749 sourceSocket.receive() 750 sourceSocket.send(ack(sourceKeys.publicKey))751 val noise = noiseNKResponder(752 sourceKeys.secretKey,753 sourceKeys.publicKey,754 sourceSocket::send,755 sourceSocket::receive,756 )757 try {758 val channel = object : PairingTransport {759 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))760 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())761 override suspend fun close() = sourceSocket.close()762 }763 764 765 receiveFrame(channel)766 } finally {767 noise.close()768 }769770 771 772 val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }773 774 775 phoneJob.cancel()776 assertNull(outcome)777 assertTrue(778 "the matching static must reach the adoption request, not be refused",779 reached.get(),780 )781 }782}