1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.withTimeoutOrNull5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Assert.fail12import org.junit.Test131415161718192021class RelayAdoptionInitiatorSourceTest {2223 242526272829 private val HANDSHAKE_TIMEOUT_MS = 5_000L3031 private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(32 deviceId = "$name-id",33 deviceName = name,34 deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,35 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),36 signingPublicKeyHex = "cd".repeat(32),37 wsPort = 49142,38 relayUrl = "wss://relay.example.test",39 )4041 private class MemoryRelaySocket(42 private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,43 private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,44 ) : RelayBinarySocket {45 override fun send(frame: ByteArray) {46 check(outbound.trySend(frame.copyOf()).isSuccess)47 }4849 override suspend fun receive(): ByteArray = inbound.receive()50 override fun close(code: Int, reason: String) {51 outbound.close()52 }53 }5455 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {56 val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(57 kotlinx.coroutines.channels.Channel.UNLIMITED,58 )59 val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(60 kotlinx.coroutines.channels.Channel.UNLIMITED,61 )62 return MemoryRelaySocket(rightToLeft, leftToRight) to63 MemoryRelaySocket(leftToRight, rightToLeft)64 }6566 @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {67 val (phoneSocket, responderSocket) = socketPair()68 val token = "AAECAwQFBgcICQoLDA0ODw"69 val relay = "wss://relay.example.test"70 val root = ByteArray(32) { (it + 5).toByte() }71 val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")72 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")73 val phoneIdentity = identity("phone", phoneKeys.publicKey)74 val responderIdentity = identity("responder", responderKeys.publicKey)75 var phoneStored: PairedDevice? = null7677 val phoneJob = async {78 runRelayAdoptionInitiatorSourceSession(79 socket = phoneSocket,80 invitation = PairingInvitation(81 relay,82 token,83 VaultStatus.EMPTY,84 Rendezvous.bytesToHex(responderKeys.publicKey),85 ),86 sourceDeps = object : AdoptionSourceDeps {87 override val sourceIdentity = phoneIdentity88 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)89 override suspend fun vaultId() = computeVaultId(root)90 override suspend fun license() = "PPK-test"91 override suspend fun onConsent(request: AdoptionFrame.Request) = true92 override suspend fun upsertPairedDevice(device: PairedDevice) {93 phoneStored = device94 }95 },96 confirm = { sas, request ->97 assertEquals(responderIdentity.deviceName, request.deviceName)98 assertEquals("extension", request.deviceType)99 assertEquals(8, sas.length)100 true101 },102 )103 }104105 106 val hello = org.json.JSONObject(String(responderSocket.receive()))107 assertEquals("PEACH_HELLO", hello.optString("type"))108 assertEquals(token, hello.optString("token"))109 responderSocket.send(110 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")111 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))112 .toString().toByteArray(),113 )114 val noise = noiseNKResponder(115 responderKeys.secretKey,116 responderKeys.publicKey,117 responderSocket::send,118 responderSocket::receive,119 )120 var responderStored: PairedDevice? = null121 try {122 val channel = object : PairingTransport {123 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))124 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())125 override suspend fun close() = responderSocket.close()126 }127 128 129 val responderJob = async {130 requestAdoption(131 channel = channel,132 deps = object : AdoptionReceiverDeps {133 override val deviceName = responderIdentity.deviceName134 override val deviceType = DeviceType.EXTENSION135 override fun generateDeviceId() = "session-only"136 override suspend fun confirmSas() = true137 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {138 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)139 return computeVaultId(hexToPubkey(rootKeyHex))140 }141 override suspend fun deriveLocalIdentity() = responderIdentity142 override suspend fun upsertPairedDevice(device: PairedDevice) {143 responderStored = device144 }145 override fun onError(error: String) = throw AssertionError(error)146 },147 )148 }149 assertNotNull(responderJob.await())150 } finally {151 noise.close()152 }153154 assertNotNull(phoneJob.await())155 assertNotNull(phoneStored)156 assertNotNull(responderStored)157 assertEquals("responder-id", phoneStored?.deviceId)158 assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)159 assertEquals(relay, phoneStored?.relayUrl)160 assertEquals(64, phoneStored?.rendezvousSecretHex?.length)161 162 assertEquals("phone-id", responderStored?.deviceId)163 assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)164 }165166 167 168 169 170 171 172 173174175176177178 private suspend fun attackerReceivesGrant(179 socket: RelayBinarySocket,180 keys: SyncIdentityKeypair,181 ) {182 val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive)183 try {184 val channel = object : PairingTransport {185 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))186 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())187 override suspend fun close() = socket.close()188 }189 sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension"))190 receiveFrame(channel)191 } finally {192 noise.close()193 }194 }195196 @Test fun `phone source rejects a substituted ACK static`() = runBlocking {197 val (phoneSocket, mitmSocket) = socketPair()198 val token = "AAECAwQFBgcICQoLDA0ODw"199 val root = ByteArray(32) { (it + 7).toByte() }200 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")201 val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")202 var rootRequested = false203204 205 206 val phoneJob = async {207 try {208 runRelayAdoptionInitiatorSourceSession(209 socket = phoneSocket,210 invitation = PairingInvitation(211 "wss://relay.example.test",212 token,213 VaultStatus.EMPTY,214 Rendezvous.bytesToHex(responderKeys.publicKey),215 ),216 sourceDeps = object : AdoptionSourceDeps {217 override val sourceIdentity =218 identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)219 override suspend fun rootKeyHex(): String {220 rootRequested = true221 return Rendezvous.bytesToHex(root)222 }223 override suspend fun vaultId() = computeVaultId(root)224 override suspend fun onConsent(request: AdoptionFrame.Request) = true225 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit226 },227 confirm = { _, _ -> true },228 )229 "completed"230 } catch (expected: IllegalArgumentException) {231 expected.message232 }233 }234235 mitmSocket.receive() 236 237 mitmSocket.send(238 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")239 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))240 .toString().toByteArray(),241 )242 243 244 val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }245246 247 248 assertEquals(249 "pairing_static_key_mismatch",250 withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },251 )252 attackerJob.cancel()253 assertFalse("root key must never be read against a substituted static", rootRequested)254 }255256 @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking {257 val (phoneSocket, mitmSocket) = socketPair()258 val root = ByteArray(32) { (it + 23).toByte() }259 val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")260261 val phoneJob = async {262 try {263 runRelayAdoptionInitiatorSourceSession(264 socket = phoneSocket,265 266 267 invitation = PairingInvitation(268 "wss://relay.example.test",269 "AAECAwQFBgcICQoLDA0ODw",270 VaultStatus.EMPTY,271 ),272 sourceDeps = object : AdoptionSourceDeps {273 override val sourceIdentity =274 identity("phone", deriveLocalSyncKeypair(root, "phone-id").publicKey)275 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)276 override suspend fun vaultId() = computeVaultId(root)277 override suspend fun onConsent(request: AdoptionFrame.Request) = true278 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit279 },280 confirm = { _, _ -> true },281 )282 "completed"283 } catch (expected: IllegalArgumentException) {284 expected.message285 }286 }287288 mitmSocket.receive() 289 mitmSocket.send(290 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")291 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))292 .toString().toByteArray(),293 )294 val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }295296 assertEquals(297 "pairing_invitation_missing_static_key",298 withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },299 )300 attackerJob.cancel()301 }302303 @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {304 val (phoneSocket, responderSocket) = socketPair()305 val token = "AAECAwQFBgcICQoLDA0ODw"306 val root = ByteArray(32) { (it + 11).toByte() }307 val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")308 val responderKeys = deriveLocalSyncKeypair(root, "responder-id")309 var rootRequested = false310 var upserted = false311312 val phoneJob = async {313 runRelayAdoptionInitiatorSourceSession(314 socket = phoneSocket,315 invitation = PairingInvitation(316 "wss://relay.example.test",317 token,318 VaultStatus.EMPTY,319 Rendezvous.bytesToHex(responderKeys.publicKey),320 ),321 sourceDeps = object : AdoptionSourceDeps {322 override val sourceIdentity = identity("phone", phoneKeys.publicKey)323 override suspend fun rootKeyHex(): String {324 rootRequested = true325 return Rendezvous.bytesToHex(root)326 }327 override suspend fun vaultId() = computeVaultId(root)328 override suspend fun onConsent(request: AdoptionFrame.Request) = true329 override suspend fun upsertPairedDevice(device: PairedDevice) {330 upserted = true331 }332 },333 confirm = { _, _ -> false },334 )335 }336337 338 responderSocket.receive() 339 responderSocket.send(340 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")341 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))342 .toString().toByteArray(),343 )344 val noise = noiseNKResponder(345 responderKeys.secretKey,346 responderKeys.publicKey,347 responderSocket::send,348 responderSocket::receive,349 )350 try {351 val channel = object : PairingTransport {352 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))353 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())354 override suspend fun close() = responderSocket.close()355 }356 357 358 sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))359 } finally {360 noise.close()361 }362363 assertNull(phoneJob.await())364 assertFalse("root key must stay on this device until SAS matches", rootRequested)365 assertFalse("paired-device store must not be touched on rejection", upserted)366 }367}368369370371372373374375376class RelayAdoptionReceiverDispatchTest {377378 @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {379 val receiver = RelayAdoptionReceiver()380 val empty = createPairingInvitation(381 "wss://relay.example.test",382 "AAECAwQFBgcICQoLDA0ODw",383 VaultStatus.EMPTY,384 )385 try {386 receiver.adopt(387 encodedInvitation = empty,388 deps = object : AdoptionReceiverDeps {389 override val deviceName = "phone"390 override val deviceType = DeviceType.ANDROID391 override fun generateDeviceId() = "x"392 override suspend fun confirmSas() = true393 override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"394 override suspend fun deriveLocalIdentity() = error("unused")395 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit396 override fun onError(error: String) = Unit397 },398 confirm = { true },399 )400 fail("adopt must reject empty invitations")401 } catch (expected: IllegalArgumentException) {402 assertTrue(403 "expected vaultStatus in the error, got: ${expected.message}",404 expected.message!!.contains("EMPTY", ignoreCase = true),405 )406 }407 }408409 @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {410 val receiver = RelayAdoptionReceiver()411 val populated = createPairingInvitation(412 "wss://relay.example.test",413 "AAECAwQFBgcICQoLDA0ODw",414 VaultStatus.POPULATED,415 )416 try {417 receiver.grantFromInvitation(418 encodedInvitation = populated,419 sourceDeps = object : AdoptionSourceDeps {420 override val sourceIdentity = LocalDeviceIdentity(421 "phone", "phone", DeviceType.ANDROID,422 "ab".repeat(32), "cd".repeat(32),423 )424 override suspend fun rootKeyHex() = "ab".repeat(32)425 override suspend fun vaultId() = "cd".repeat(32)426 override suspend fun onConsent(request: AdoptionFrame.Request) = true427 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit428 },429 confirm = { _, _ -> true },430 )431 fail("grantFromInvitation must reject populated invitations")432 } catch (expected: IllegalArgumentException) {433 assertTrue(434 "expected vaultStatus in the error, got: ${expected.message}",435 expected.message!!.contains("POPULATED", ignoreCase = true),436 )437 }438 }439}