1package com.peachpasswords.sync23import java.util.concurrent.atomic.AtomicBoolean4import kotlinx.coroutines.async5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.json.JSONObject8import org.junit.Assert.assertEquals9import org.junit.Assert.assertFalse10import org.junit.Assert.assertNotNull11import org.junit.Assert.assertNull12import org.junit.Assert.assertTrue13import org.junit.Test1415class RelayAdoptionHostTest {16 private class MemoryRelaySocket(17 private val inbound: Channel<ByteArray>,18 private val outbound: Channel<ByteArray>,19 ) : RelayBinarySocket {20 override fun send(frame: ByteArray) {21 check(outbound.trySend(frame.copyOf()).isSuccess)22 }2324 override suspend fun receive(): ByteArray = inbound.receive()25 override fun close(code: Int, reason: String) {26 outbound.close()27 }28 }2930 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {31 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)32 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)33 return MemoryRelaySocket(rightToLeft, leftToRight) to34 MemoryRelaySocket(leftToRight, rightToLeft)35 }3637 private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(38 deviceId = "$name-id",39 deviceName = name,40 deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,41 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),42 signingPublicKeyHex = "cd".repeat(32),43 wsPort = 49142,44 relayUrl = "wss://relay.example.test",45 )4647 @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {48 val (sourceSocket, receiverSocket) = socketPair()49 val token = "AAECAwQFBgcICQoLDA0ODw"50 val relay = "wss://relay.example.test"51 val sourceRoot = ByteArray(32) { (it + 1).toByte() }52 val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")53 val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")54 val sourceIdentity = identity("source", sourceKeys.publicKey)55 val receiverIdentity = identity("receiver", receiverKeys.publicKey)56 var sourceStored: PairedDevice? = null57 var receiverStored: PairedDevice? = null5859 val sourceJob = async {60 runRelayAdoptionSourceSession(61 socket = sourceSocket,62 expectedToken = token,63 relayUrl = relay,64 ourStaticPrivateKey = sourceKeys.secretKey,65 ourStaticPublicKey = sourceKeys.publicKey,66 localIdentity = sourceIdentity,67 sourceDeps = object : AdoptionSourceDeps {68 override val sourceIdentity = sourceIdentity69 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)70 override suspend fun vaultId() = computeVaultId(sourceRoot)71 override suspend fun onConsent(request: AdoptionFrame.Request) = true72 override suspend fun upsertPairedDevice(device: PairedDevice) {73 sourceStored = device74 }75 },76 confirm = { sas, request ->77 assertEquals("receiver", request.deviceName)78 assertEquals(8, sas.length)79 true80 },81 )82 }8384 receiverSocket.send(85 JSONObject().put("type", "PEACH_HELLO").put("token", token)86 .toString().toByteArray(),87 )88 val ack = JSONObject(String(receiverSocket.receive()))89 val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))90 val noise = noiseNKInitiator(91 sourcePublic,92 receiverSocket::send,93 receiverSocket::receive,94 )95 val channel = encryptedChannel(receiverSocket, noise)96 val received = requestAdoption(97 channel,98 object : AdoptionReceiverDeps {99 override val deviceName = "receiver"100 override val deviceType = DeviceType.EXTENSION101 override val pairState = AdoptionPairState(102 Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),103 relay,104 )105 override fun generateDeviceId() = "session-request"106 override suspend fun confirmSas() = true107 override suspend fun createVaultFromRootKey(rootKeyHex: String) =108 computeVaultId(hexToPubkey(rootKeyHex))109 override suspend fun deriveLocalIdentity() = receiverIdentity110 override suspend fun upsertPairedDevice(device: PairedDevice) {111 receiverStored = device112 }113 override fun onError(error: String) = throw AssertionError(error)114 },115 )116117 assertNotNull(received)118 assertNotNull(sourceJob.await())119 assertEquals(relay, receiverStored?.relayUrl)120 assertEquals(relay, sourceStored?.relayUrl)121 assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)122 assertEquals(64, receiverStored?.rendezvousSecretHex?.length)123 }124125 @Test fun `source rejection never requests the root`() = runBlocking {126 val (sourceSocket, receiverSocket) = socketPair()127 val token = "AAECAwQFBgcICQoLDA0ODw"128 val root = ByteArray(32) { (it + 7).toByte() }129 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")130 val rootRequested = AtomicBoolean(false)131132 val sourceJob = async {133 runRelayAdoptionSourceSession(134 sourceSocket,135 token,136 "wss://relay.example.test",137 sourceKeys.secretKey,138 sourceKeys.publicKey,139 identity("source", sourceKeys.publicKey),140 object : AdoptionSourceDeps {141 override val sourceIdentity = identity("source", sourceKeys.publicKey)142 override suspend fun rootKeyHex(): String {143 rootRequested.set(true)144 return Rendezvous.bytesToHex(root)145 }146 override suspend fun vaultId() = computeVaultId(root)147 override suspend fun onConsent(request: AdoptionFrame.Request) = true148 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit149 },150 confirm = { _, _ -> false },151 )152 }153 receiverSocket.send(154 JSONObject().put("type", "PEACH_HELLO").put("token", token)155 .toString().toByteArray(),156 )157 val ack = JSONObject(String(receiverSocket.receive()))158 val noise = noiseNKInitiator(159 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),160 receiverSocket::send,161 receiverSocket::receive,162 )163 val channel = encryptedChannel(receiverSocket, noise)164 sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))165166 assertNull(sourceJob.await())167 assertFalse(rootRequested.get())168 }169170 @Test fun `receiver session completes real Noise adoption with matching pair state`() = runBlocking {171 val (sourceSocket, receiverSocket) = socketPair()172 val token = "AAECAwQFBgcICQoLDA0ODw"173 val relay = "wss://relay.example.test"174 val root = ByteArray(32) { (it + 3).toByte() }175 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")176 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")177 val sourceIdentity = identity("source", sourceKeys.publicKey)178 val receiverIdentity = identity("receiver", receiverKeys.publicKey)179 var sourceStored: PairedDevice? = null180 var receiverStored: PairedDevice? = null181 var sourceSas = ""182 var receiverSas = ""183184 val sourceJob = async {185 runRelayAdoptionSourceSession(186 sourceSocket,187 token,188 relay,189 sourceKeys.secretKey,190 sourceKeys.publicKey,191 sourceIdentity,192 object : AdoptionSourceDeps {193 override val sourceIdentity = sourceIdentity194 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)195 override suspend fun vaultId() = computeVaultId(root)196 override suspend fun onConsent(request: AdoptionFrame.Request) = true197 override suspend fun upsertPairedDevice(device: PairedDevice) {198 sourceStored = device199 }200 },201 confirm = { sas, _ ->202 sourceSas = sas203 true204 },205 )206 }207208 val received = runRelayAdoptionReceiverSession(209 receiverSocket,210 PairingInvitation(relay, token),211 object : AdoptionReceiverDeps {212 override val deviceName = "receiver"213 override val deviceType = DeviceType.ANDROID214 override fun generateDeviceId() = "session-only-request"215 override suspend fun confirmSas() = error("session wrapper owns SAS")216 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {217 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)218 return computeVaultId(hexToPubkey(rootKeyHex))219 }220 override suspend fun deriveLocalIdentity() = receiverIdentity221 override suspend fun upsertPairedDevice(device: PairedDevice) {222 receiverStored = device223 }224 override fun onError(error: String) = throw AssertionError(error)225 },226 confirm = { sas ->227 receiverSas = sas228 true229 },230 )231232 assertNotNull(received)233 assertNotNull(sourceJob.await())234 assertEquals(sourceSas, receiverSas)235 assertEquals(relay, sourceStored?.relayUrl)236 assertEquals(relay, receiverStored?.relayUrl)237 assertEquals(sourceStored?.rendezvousSecretHex, receiverStored?.rendezvousSecretHex)238 }239240 @Test fun `receiver rejection is encrypted and source never reads root`() = runBlocking {241 val (sourceSocket, receiverSocket) = socketPair()242 val token = "AAECAwQFBgcICQoLDA0ODw"243 val relay = "wss://relay.example.test"244 val root = ByteArray(32) { (it + 9).toByte() }245 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")246 val rootRequested = AtomicBoolean(false)247248 val sourceJob = async {249 runRelayAdoptionSourceSession(250 sourceSocket,251 token,252 relay,253 sourceKeys.secretKey,254 sourceKeys.publicKey,255 identity("source", sourceKeys.publicKey),256 object : AdoptionSourceDeps {257 override val sourceIdentity = identity("source", sourceKeys.publicKey)258 override suspend fun rootKeyHex(): String {259 rootRequested.set(true)260 return Rendezvous.bytesToHex(root)261 }262 override suspend fun vaultId() = computeVaultId(root)263 override suspend fun onConsent(request: AdoptionFrame.Request) = true264 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit265 },266 confirm = { _, _ -> true },267 )268 }269 var created = false270 val received = runRelayAdoptionReceiverSession(271 receiverSocket,272 PairingInvitation(relay, token),273 object : AdoptionReceiverDeps {274 override val deviceName = "receiver"275 override val deviceType = DeviceType.ANDROID276 override fun generateDeviceId() = "session-only-request"277 override suspend fun confirmSas() = error("session wrapper owns SAS")278 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {279 created = true280 return computeVaultId(hexToPubkey(rootKeyHex))281 }282 override suspend fun deriveLocalIdentity() =283 identity("receiver", sourceKeys.publicKey)284 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit285 override fun onError(error: String) {286 assertEquals("ADOPT_SAS_REJECTED", error)287 }288 },289 confirm = { false },290 )291292 assertNull(received)293 assertNull(sourceJob.await())294 assertFalse(rootRequested.get())295 assertFalse(created)296 }297298 @Test fun `malformed receiver ACK fails before any vault mutation`() = runBlocking {299 val (unusedSource, receiverSocket) = socketPair()300 unusedSource.send(301 JSONObject()302 .put("type", "PEACH_HELLO_ACK")303 .put("ephemeralPublicKeyHex", "ab")304 .toString()305 .toByteArray(),306 )307 var created = false308 val result = runCatching {309 runRelayAdoptionReceiverSession(310 receiverSocket,311 PairingInvitation(312 "wss://relay.example.test",313 "AAECAwQFBgcICQoLDA0ODw",314 ),315 object : AdoptionReceiverDeps {316 override val deviceName = "receiver"317 override val deviceType = DeviceType.ANDROID318 override fun generateDeviceId() = "request"319 override suspend fun confirmSas() = false320 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {321 created = true322 return ""323 }324 override suspend fun deriveLocalIdentity() =325 error("must not derive identity")326 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit327 override fun onError(error: String) = Unit328 },329 confirm = { true },330 )331 }332 assertTrue(result.isFailure)333 assertFalse(created)334 }335336 private fun encryptedChannel(337 socket: RelayBinarySocket,338 noise: NoiseNKSession,339 ): PairingTransport = object : PairingTransport {340 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))341 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())342 override suspend fun close() = socket.close()343 }344}