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 license() = "PPK-test"77 override suspend fun onConsent(request: AdoptionFrame.Request) = true78 override suspend fun upsertPairedDevice(device: PairedDevice) {79 phoneStored = device80 }81 },82 confirm = { sas, request ->83 assertEquals(responderIdentity.deviceName, request.deviceName)84 assertEquals("extension", request.deviceType)85 assertEquals(8, sas.length)86 true87 },88 )89 }9091 92 val hello = org.json.JSONObject(String(responderSocket.receive()))93 assertEquals("PEACH_HELLO", hello.optString("type"))94 assertEquals(token, hello.optString("token"))95 responderSocket.send(96 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")97 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))98 .toString().toByteArray(),99 )100 val noise = noiseNKResponder(101 responderKeys.secretKey,102 responderKeys.publicKey,103 responderSocket::send,104 responderSocket::receive,105 )106 var responderStored: PairedDevice? = null107 try {108 val channel = object : PairingTransport {109 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))110 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())111 override suspend fun close() = responderSocket.close()112 }113 114 115 val responderJob = async {116 requestAdoption(117 channel = channel,118 deps = object : AdoptionReceiverDeps {119 override val deviceName = responderIdentity.deviceName120 override val deviceType = DeviceType.EXTENSION121 override fun generateDeviceId() = "session-only"122 override suspend fun confirmSas() = true123 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {124 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)125 return computeVaultId(hexToPubkey(rootKeyHex))126 }127 override suspend fun deriveLocalIdentity() = responderIdentity128 override suspend fun upsertPairedDevice(device: PairedDevice) {129 responderStored = device130 }131 override fun onError(error: String) = throw AssertionError(error)132 },133 )134 }135 assertNotNull(responderJob.await())136 } finally {137 noise.close()138 }139140 assertNotNull(phoneJob.await())141 assertNotNull(phoneStored)142 assertNotNull(responderStored)143 assertEquals("responder-id", phoneStored?.deviceId)144 assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)145 assertEquals(relay, phoneStored?.relayUrl)146 assertEquals(64, phoneStored?.rendezvousSecretHex?.length)147 148 assertEquals("phone-id", responderStored?.deviceId)149 assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)150 }151152 @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {153 val (phoneSocket, responderSocket) = socketPair()154 val token = "AAECAwQFBgcICQoLDA0ODw"155 val root = ByteArray(32) { (it + 11).toByte() }156 val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")157 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")158 var rootRequested = false159 var upserted = false160161 val phoneJob = async {162 runRelayAdoptionInitiatorSourceSession(163 socket = phoneSocket,164 invitation = PairingInvitation(165 "wss://relay.example.test",166 token,167 VaultStatus.EMPTY,168 ),169 sourceDeps = object : AdoptionSourceDeps {170 override val sourceIdentity = identity("phone", phoneKeys.publicKey)171 override suspend fun rootKeyHex(): String {172 rootRequested = true173 return Rendezvous.bytesToHex(root)174 }175 override suspend fun vaultId() = computeVaultId(root)176 override suspend fun onConsent(request: AdoptionFrame.Request) = true177 override suspend fun upsertPairedDevice(device: PairedDevice) {178 upserted = true179 }180 },181 confirm = { _, _ -> false },182 )183 }184185 186 responderSocket.receive() 187 responderSocket.send(188 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")189 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))190 .toString().toByteArray(),191 )192 val noise = noiseNKResponder(193 responderKeys.secretKey,194 responderKeys.publicKey,195 responderSocket::send,196 responderSocket::receive,197 )198 try {199 val channel = object : PairingTransport {200 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))201 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())202 override suspend fun close() = responderSocket.close()203 }204 205 206 sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))207 } finally {208 noise.close()209 }210211 assertNull(phoneJob.await())212 assertFalse("root key must stay on this device until SAS matches", rootRequested)213 assertFalse("paired-device store must not be touched on rejection", upserted)214 }215}216217218219220221222223224class RelayAdoptionReceiverDispatchTest {225226 @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {227 val receiver = RelayAdoptionReceiver()228 val empty = createPairingInvitation(229 "wss://relay.example.test",230 "AAECAwQFBgcICQoLDA0ODw",231 VaultStatus.EMPTY,232 )233 try {234 receiver.adopt(235 encodedInvitation = empty,236 deps = object : AdoptionReceiverDeps {237 override val deviceName = "phone"238 override val deviceType = DeviceType.ANDROID239 override fun generateDeviceId() = "x"240 override suspend fun confirmSas() = true241 override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"242 override suspend fun deriveLocalIdentity() = error("unused")243 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit244 override fun onError(error: String) = Unit245 },246 confirm = { true },247 )248 fail("adopt must reject empty invitations")249 } catch (expected: IllegalArgumentException) {250 assertTrue(251 "expected vaultStatus in the error, got: ${expected.message}",252 expected.message!!.contains("EMPTY", ignoreCase = true),253 )254 }255 }256257 @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {258 val receiver = RelayAdoptionReceiver()259 val populated = createPairingInvitation(260 "wss://relay.example.test",261 "AAECAwQFBgcICQoLDA0ODw",262 VaultStatus.POPULATED,263 )264 try {265 receiver.grantFromInvitation(266 encodedInvitation = populated,267 sourceDeps = object : AdoptionSourceDeps {268 override val sourceIdentity = LocalDeviceIdentity(269 "phone", "phone", DeviceType.ANDROID,270 "ab".repeat(32), "cd".repeat(32),271 )272 override suspend fun rootKeyHex() = "ab".repeat(32)273 override suspend fun vaultId() = "cd".repeat(32)274 override suspend fun onConsent(request: AdoptionFrame.Request) = true275 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit276 },277 confirm = { _, _ -> true },278 )279 fail("grantFromInvitation must reject populated invitations")280 } catch (expected: IllegalArgumentException) {281 assertTrue(282 "expected vaultStatus in the error, got: ${expected.message}",283 expected.message!!.contains("POPULATED", ignoreCase = true),284 )285 }286 }287}