1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertFalse7import org.junit.Assert.assertNotNull8import org.junit.Assert.assertNull9import org.junit.Assert.assertTrue10import org.junit.Assert.fail11import org.junit.Test121314151617181920class RelayAdoptionInitiatorSourceTest {2122 private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(23 deviceId = "$name-id",24 deviceName = name,25 deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,26 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),27 signingPublicKeyHex = "cd".repeat(32),28 wsPort = 49142,29 relayUrl = "wss://relay.example.test",30 )3132 private class MemoryRelaySocket(33 private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,34 private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,35 ) : RelayBinarySocket {36 override fun send(frame: ByteArray) {37 check(outbound.trySend(frame.copyOf()).isSuccess)38 }3940 override suspend fun receive(): ByteArray = inbound.receive()41 override fun close(code: Int, reason: String) {42 outbound.close()43 }44 }4546 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {47 val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(48 kotlinx.coroutines.channels.Channel.UNLIMITED,49 )50 val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(51 kotlinx.coroutines.channels.Channel.UNLIMITED,52 )53 return MemoryRelaySocket(rightToLeft, leftToRight) to54 MemoryRelaySocket(leftToRight, rightToLeft)55 }5657 @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {58 val (phoneSocket, responderSocket) = socketPair()59 val token = "AAECAwQFBgcICQoLDA0ODw"60 val relay = "wss://relay.example.test"61 val root = ByteArray(32) { (it + 5).toByte() }62 val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")63 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")64 val phoneIdentity = identity("phone", phoneKeys.publicKey)65 val responderIdentity = identity("responder", responderKeys.publicKey)66 var phoneStored: PairedDevice? = null6768 val phoneJob = async {69 runRelayAdoptionInitiatorSourceSession(70 socket = phoneSocket,71 invitation = PairingInvitation(relay, token, VaultStatus.EMPTY),72 sourceDeps = object : AdoptionSourceDeps {73 override val sourceIdentity = phoneIdentity74 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)75 override suspend fun vaultId() = computeVaultId(root)76 override suspend fun onConsent(request: AdoptionFrame.Request) = true77 override suspend fun upsertPairedDevice(device: PairedDevice) {78 phoneStored = device79 }80 },81 confirm = { sas, request ->82 assertEquals(responderIdentity.deviceName, request.deviceName)83 assertEquals("extension", request.deviceType)84 assertEquals(8, sas.length)85 true86 },87 )88 }8990 91 val hello = org.json.JSONObject(String(responderSocket.receive()))92 assertEquals("PEACH_HELLO", hello.optString("type"))93 assertEquals(token, hello.optString("token"))94 responderSocket.send(95 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")96 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))97 .toString().toByteArray(),98 )99 val noise = noiseNKResponder(100 responderKeys.secretKey,101 responderKeys.publicKey,102 responderSocket::send,103 responderSocket::receive,104 )105 var responderStored: PairedDevice? = null106 try {107 val channel = object : PairingTransport {108 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))109 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())110 override suspend fun close() = responderSocket.close()111 }112 113 114 val responderJob = async {115 requestAdoption(116 channel = channel,117 deps = object : AdoptionReceiverDeps {118 override val deviceName = responderIdentity.deviceName119 override val deviceType = DeviceType.EXTENSION120 override fun generateDeviceId() = "session-only"121 override suspend fun confirmSas() = true122 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {123 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)124 return computeVaultId(hexToPubkey(rootKeyHex))125 }126 override suspend fun deriveLocalIdentity() = responderIdentity127 override suspend fun upsertPairedDevice(device: PairedDevice) {128 responderStored = device129 }130 override fun onError(error: String) = throw AssertionError(error)131 },132 )133 }134 assertNotNull(responderJob.await())135 } finally {136 noise.close()137 }138139 assertNotNull(phoneJob.await())140 assertNotNull(phoneStored)141 assertNotNull(responderStored)142 assertEquals("responder-id", phoneStored?.deviceId)143 assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)144 assertEquals(relay, phoneStored?.relayUrl)145 assertEquals(64, phoneStored?.rendezvousSecretHex?.length)146 147 assertEquals("phone-id", responderStored?.deviceId)148 assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)149 }150151 @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {152 val (phoneSocket, responderSocket) = socketPair()153 val token = "AAECAwQFBgcICQoLDA0ODw"154 val root = ByteArray(32) { (it + 11).toByte() }155 val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")156 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")157 var rootRequested = false158 var upserted = false159160 val phoneJob = async {161 runRelayAdoptionInitiatorSourceSession(162 socket = phoneSocket,163 invitation = PairingInvitation(164 "wss://relay.example.test",165 token,166 VaultStatus.EMPTY,167 ),168 sourceDeps = object : AdoptionSourceDeps {169 override val sourceIdentity = identity("phone", phoneKeys.publicKey)170 override suspend fun rootKeyHex(): String {171 rootRequested = true172 return Rendezvous.bytesToHex(root)173 }174 override suspend fun vaultId() = computeVaultId(root)175 override suspend fun onConsent(request: AdoptionFrame.Request) = true176 override suspend fun upsertPairedDevice(device: PairedDevice) {177 upserted = true178 }179 },180 confirm = { _, _ -> false },181 )182 }183184 185 responderSocket.receive() 186 responderSocket.send(187 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")188 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))189 .toString().toByteArray(),190 )191 val noise = noiseNKResponder(192 responderKeys.secretKey,193 responderKeys.publicKey,194 responderSocket::send,195 responderSocket::receive,196 )197 try {198 val channel = object : PairingTransport {199 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))200 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())201 override suspend fun close() = responderSocket.close()202 }203 204 205 sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))206 } finally {207 noise.close()208 }209210 assertNull(phoneJob.await())211 assertFalse("root key must stay on this device until SAS matches", rootRequested)212 assertFalse("paired-device store must not be touched on rejection", upserted)213 }214}215216217218219220221222223class RelayAdoptionReceiverDispatchTest {224225 @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {226 val receiver = RelayAdoptionReceiver()227 val empty = createPairingInvitation(228 "wss://relay.example.test",229 "AAECAwQFBgcICQoLDA0ODw",230 VaultStatus.EMPTY,231 )232 try {233 receiver.adopt(234 encodedInvitation = empty,235 deps = object : AdoptionReceiverDeps {236 override val deviceName = "phone"237 override val deviceType = DeviceType.ANDROID238 override fun generateDeviceId() = "x"239 override suspend fun confirmSas() = true240 override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"241 override suspend fun deriveLocalIdentity() = error("unused")242 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit243 override fun onError(error: String) = Unit244 },245 confirm = { true },246 )247 fail("adopt must reject empty invitations")248 } catch (expected: IllegalArgumentException) {249 assertTrue(250 "expected vaultStatus in the error, got: ${expected.message}",251 expected.message!!.contains("EMPTY", ignoreCase = true),252 )253 }254 }255256 @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {257 val receiver = RelayAdoptionReceiver()258 val populated = createPairingInvitation(259 "wss://relay.example.test",260 "AAECAwQFBgcICQoLDA0ODw",261 VaultStatus.POPULATED,262 )263 try {264 receiver.grantFromInvitation(265 encodedInvitation = populated,266 sourceDeps = object : AdoptionSourceDeps {267 override val sourceIdentity = LocalDeviceIdentity(268 "phone", "phone", DeviceType.ANDROID,269 "ab".repeat(32), "cd".repeat(32),270 )271 override suspend fun rootKeyHex() = "ab".repeat(32)272 override suspend fun vaultId() = "cd".repeat(32)273 override suspend fun onConsent(request: AdoptionFrame.Request) = true274 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit275 },276 confirm = { _, _ -> true },277 )278 fail("grantFromInvitation must reject populated invitations")279 } catch (expected: IllegalArgumentException) {280 assertTrue(281 "expected vaultStatus in the error, got: ${expected.message}",282 expected.message!!.contains("POPULATED", ignoreCase = true),283 )284 }285 }286}