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.Test1314class RelayAdoptionHostTest {15 private class MemoryRelaySocket(16 private val inbound: Channel<ByteArray>,17 private val outbound: Channel<ByteArray>,18 ) : RelayBinarySocket {19 override fun send(frame: ByteArray) {20 check(outbound.trySend(frame.copyOf()).isSuccess)21 }2223 override suspend fun receive(): ByteArray = inbound.receive()24 override fun close(code: Int, reason: String) {25 outbound.close()26 }27 }2829 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)31 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)32 return MemoryRelaySocket(rightToLeft, leftToRight) to33 MemoryRelaySocket(leftToRight, rightToLeft)34 }3536 private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(37 deviceId = "$name-id",38 deviceName = name,39 deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,40 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),41 signingPublicKeyHex = "cd".repeat(32),42 wsPort = 49142,43 relayUrl = "wss://relay.example.test",44 )4546 @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {47 val (sourceSocket, receiverSocket) = socketPair()48 val token = "AAECAwQFBgcICQoLDA0ODw"49 val relay = "wss://relay.example.test"50 val sourceRoot = ByteArray(32) { (it + 1).toByte() }51 val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")52 val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")53 val sourceIdentity = identity("source", sourceKeys.publicKey)54 val receiverIdentity = identity("receiver", receiverKeys.publicKey)55 var sourceStored: PairedDevice? = null56 var receiverStored: PairedDevice? = null5758 val sourceJob = async {59 runRelayAdoptionSourceSession(60 socket = sourceSocket,61 expectedToken = token,62 relayUrl = relay,63 ourStaticPrivateKey = sourceKeys.secretKey,64 ourStaticPublicKey = sourceKeys.publicKey,65 localIdentity = sourceIdentity,66 sourceDeps = object : AdoptionSourceDeps {67 override val sourceIdentity = sourceIdentity68 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)69 override suspend fun vaultId() = computeVaultId(sourceRoot)70 override suspend fun onConsent(request: AdoptionFrame.Request) = true71 override suspend fun upsertPairedDevice(device: PairedDevice) {72 sourceStored = device73 }74 },75 confirm = { sas, request ->76 assertEquals("receiver", request.deviceName)77 assertEquals(6, sas.length)78 true79 },80 )81 }8283 receiverSocket.send(84 JSONObject().put("type", "PEACH_HELLO").put("token", token)85 .toString().toByteArray(),86 )87 val ack = JSONObject(String(receiverSocket.receive()))88 val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))89 val noise = noiseNKInitiator(90 sourcePublic,91 receiverSocket::send,92 receiverSocket::receive,93 )94 val channel = encryptedChannel(receiverSocket, noise)95 val received = requestAdoption(96 channel,97 object : AdoptionReceiverDeps {98 override val deviceName = "receiver"99 override val deviceType = DeviceType.EXTENSION100 override val pairState = AdoptionPairState(101 Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),102 relay,103 )104 override fun generateDeviceId() = "session-request"105 override suspend fun confirmSas() = true106 override suspend fun createVaultFromRootKey(rootKeyHex: String) =107 computeVaultId(hexToPubkey(rootKeyHex))108 override suspend fun deriveLocalIdentity() = receiverIdentity109 override suspend fun upsertPairedDevice(device: PairedDevice) {110 receiverStored = device111 }112 override fun onError(error: String) = throw AssertionError(error)113 },114 )115116 assertNotNull(received)117 assertNotNull(sourceJob.await())118 assertEquals(relay, receiverStored?.relayUrl)119 assertEquals(relay, sourceStored?.relayUrl)120 assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)121 assertEquals(64, receiverStored?.rendezvousSecretHex?.length)122 }123124 @Test fun `source rejection never requests the root`() = runBlocking {125 val (sourceSocket, receiverSocket) = socketPair()126 val token = "AAECAwQFBgcICQoLDA0ODw"127 val root = ByteArray(32) { (it + 7).toByte() }128 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")129 val rootRequested = AtomicBoolean(false)130131 val sourceJob = async {132 runRelayAdoptionSourceSession(133 sourceSocket,134 token,135 "wss://relay.example.test",136 sourceKeys.secretKey,137 sourceKeys.publicKey,138 identity("source", sourceKeys.publicKey),139 object : AdoptionSourceDeps {140 override val sourceIdentity = identity("source", sourceKeys.publicKey)141 override suspend fun rootKeyHex(): String {142 rootRequested.set(true)143 return Rendezvous.bytesToHex(root)144 }145 override suspend fun vaultId() = computeVaultId(root)146 override suspend fun onConsent(request: AdoptionFrame.Request) = true147 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit148 },149 confirm = { _, _ -> false },150 )151 }152 receiverSocket.send(153 JSONObject().put("type", "PEACH_HELLO").put("token", token)154 .toString().toByteArray(),155 )156 val ack = JSONObject(String(receiverSocket.receive()))157 val noise = noiseNKInitiator(158 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),159 receiverSocket::send,160 receiverSocket::receive,161 )162 val channel = encryptedChannel(receiverSocket, noise)163 sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))164165 assertNull(sourceJob.await())166 assertFalse(rootRequested.get())167 }168169 private fun encryptedChannel(170 socket: RelayBinarySocket,171 noise: NoiseNKSession,172 ): PairingTransport = object : PairingTransport {173 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))174 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())175 override suspend fun close() = socket.close()176 }177}