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 = deriveSyncIdentityKeypair(root, "phone-id")72 val responderKeys = deriveSyncIdentityKeypair(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 : AdoptionSourceV2Deps {87 override suspend fun rotateSourceIdentity() = sourceIdentity88 override fun hasDeviceIdentityCollision(device: PairedDevice) = false89 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true90 override suspend fun completePendingIdentity(device: PairedDevice) = true91 override fun snapshotCapable() = true9293 override val sourceIdentity = phoneIdentity94 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)95 override suspend fun vaultId() = computeVaultId(root)96 override suspend fun license() = "PPK-test"97 override suspend fun onConsent(request: AdoptionFrame.Request) = true98 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true99 override suspend fun upsertPairedDevice(device: PairedDevice) {100 phoneStored = device101 }102 },103 confirm = { sas, request ->104 assertEquals(responderIdentity.deviceName, request.deviceName)105 assertEquals("extension", request.deviceType)106 assertEquals(8, sas.length)107 true108 },109 )110 }111112 113 val hello = org.json.JSONObject(String(responderSocket.receive()))114 assertEquals("PEACH_HELLO", hello.optString("type"))115 assertEquals(token, hello.optString("token"))116 responderSocket.send(117 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")118 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))119 .toString().toByteArray(),120 )121 val noise = noiseNKResponder(122 responderKeys.secretKey,123 responderKeys.publicKey,124 responderSocket::send,125 responderSocket::receive,126 )127 var responderStored: PairedDevice? = null128 try {129 val channel = object : PairingTransport {130 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))131 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())132 override suspend fun close() = responderSocket.close()133 }134 135 136 val responderJob = async {137 requestAdoption(138 channel = channel,139 deps = object : AdoptionReceiverV2Deps {140 override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")141 override suspend fun rotateLocalIdentity() = localIdentity()142 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}143 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false144145 override val deviceName = responderIdentity.deviceName146 override val deviceType = DeviceType.EXTENSION147 override fun generateDeviceId() = "session-only"148 override suspend fun confirmSas() = true149 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {150 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)151 return computeVaultId(hexToPubkey(rootKeyHex))152 }153 override suspend fun deriveLocalIdentity() = responderIdentity154 override suspend fun upsertPairedDevice(device: PairedDevice) {155 responderStored = device156 }157 override fun onError(error: String) = throw AssertionError(error)158 },159 )160 }161 assertNotNull(responderJob.await())162 } finally {163 noise.close()164 }165166 assertNotNull(phoneJob.await())167 assertNotNull(phoneStored)168 assertNotNull(responderStored)169 assertEquals("responder-id", phoneStored?.deviceId)170 assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)171 assertEquals(relay, phoneStored?.relayUrl)172 assertEquals(64, phoneStored?.rendezvousSecretHex?.length)173 174 assertEquals("phone-id", responderStored?.deviceId)175 assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)176 }177178 @Test fun `phone source rotates after browser rejects its revoked identity`() = runBlocking {179 val (phoneSocket, responderSocket) = socketPair()180 val token = "AAECAwQFBgcICQoLDA0ODw"181 val relay = "wss://relay.example.test"182 val root = ByteArray(32) { (it + 19).toByte() }183 val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id")184 val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")185 val phoneIdentity = identity("phone", phoneKeys.publicKey)186 val rotatedPhoneIdentity = identity(187 "phone-fresh",188 deriveSyncIdentityKeypair(root, "phone-fresh-id").publicKey,189 ).copy(deviceId = "phone-fresh-id")190 val responderIdentity = identity("responder", responderKeys.publicKey)191 var rotations = 0192193 val phoneJob = async {194 runRelayAdoptionInitiatorSourceSession(195 socket = phoneSocket,196 invitation = PairingInvitation(197 relay,198 token,199 VaultStatus.EMPTY,200 Rendezvous.bytesToHex(responderKeys.publicKey),201 ),202 sourceDeps = object : AdoptionSourceV2Deps {203 override fun hasDeviceIdentityCollision(device: PairedDevice) = false204 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true205 override suspend fun completePendingIdentity(device: PairedDevice) = true206 override fun snapshotCapable() = true207208 override val sourceIdentity = phoneIdentity209 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)210 override suspend fun vaultId() = computeVaultId(root)211 override suspend fun license() = "PPK-test"212 override suspend fun onConsent(request: AdoptionFrame.Request) = true213 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true214 override suspend fun rotateSourceIdentity(): LocalDeviceIdentity {215 rotations++216 return rotatedPhoneIdentity217 }218 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit219 },220 confirm = { _, _ -> true },221 )222 }223224 val hello = org.json.JSONObject(String(responderSocket.receive()))225 assertEquals("PEACH_HELLO", hello.optString("type"))226 responderSocket.send(227 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")228 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))229 .toString().toByteArray(),230 )231 val noise = noiseNKResponder(232 responderKeys.secretKey,233 responderKeys.publicKey,234 responderSocket::send,235 responderSocket::receive,236 )237 try {238 val channel = object : PairingTransport {239 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))240 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())241 override suspend fun close() = responderSocket.close()242 }243 val responderJob = async {244 requestAdoption(245 channel,246 object : AdoptionReceiverV2Deps {247 override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")248 override suspend fun rotateLocalIdentity() = localIdentity()249 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}250 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false251252 override val deviceName = responderIdentity.deviceName253 override val deviceType = DeviceType.EXTENSION254 override fun generateDeviceId() = "session-only"255 override suspend fun confirmSas() = true256 override suspend fun createVaultFromRootKey(rootKeyHex: String) =257 computeVaultId(hexToPubkey(rootKeyHex))258 override suspend fun deriveLocalIdentity() = responderIdentity259 override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source) =260 source.deviceId == phoneIdentity.deviceId261 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit262 override fun onError(error: String) = throw AssertionError(error)263 },264 )265 }266 assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { responderJob.await() })267 } finally {268 noise.close()269 }270271 assertNotNull(withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() })272 assertEquals(1, rotations)273 }274275 276 277 278 279 280 281 282283284285286287 private suspend fun attackerReceivesGrant(288 socket: RelayBinarySocket,289 keys: SyncIdentityKeypair,290 ) {291 val noise = noiseNKResponder(keys.secretKey, keys.publicKey, socket::send, socket::receive)292 try {293 val channel = object : PairingTransport {294 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))295 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())296 override suspend fun close() = socket.close()297 }298 sendFrame(channel, AdoptionFrame.Request("attacker-session", "Empty Browser", "extension"))299 receiveFrame(channel)300 } finally {301 noise.close()302 }303 }304305 @Test fun `phone source rejects a substituted ACK static`() = runBlocking {306 val (phoneSocket, mitmSocket) = socketPair()307 val token = "AAECAwQFBgcICQoLDA0ODw"308 val root = ByteArray(32) { (it + 7).toByte() }309 val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")310 val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id")311 var rootRequested = false312313 314 315 val phoneJob = async {316 try {317 runRelayAdoptionInitiatorSourceSession(318 socket = phoneSocket,319 invitation = PairingInvitation(320 "wss://relay.example.test",321 token,322 VaultStatus.EMPTY,323 Rendezvous.bytesToHex(responderKeys.publicKey),324 ),325 sourceDeps = object : AdoptionSourceV2Deps {326 override suspend fun rotateSourceIdentity() = sourceIdentity327 override fun hasDeviceIdentityCollision(device: PairedDevice) = false328 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true329 override suspend fun completePendingIdentity(device: PairedDevice) = true330 override fun snapshotCapable() = true331332 override val sourceIdentity =333 identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey)334 override suspend fun rootKeyHex(): String {335 rootRequested = true336 return Rendezvous.bytesToHex(root)337 }338 override suspend fun vaultId() = computeVaultId(root)339 override suspend fun onConsent(request: AdoptionFrame.Request) = true340 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true341 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit342 },343 confirm = { _, _ -> true },344 )345 "completed"346 } catch (expected: IllegalArgumentException) {347 expected.message348 }349 }350351 mitmSocket.receive() 352 353 mitmSocket.send(354 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")355 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))356 .toString().toByteArray(),357 )358 359 360 val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }361362 363 364 assertEquals(365 "pairing_static_key_mismatch",366 withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },367 )368 attackerJob.cancel()369 assertFalse("root key must never be read against a substituted static", rootRequested)370 }371372 @Test fun `phone source rejects an invitation that pins no static key`() = runBlocking {373 val (phoneSocket, mitmSocket) = socketPair()374 val root = ByteArray(32) { (it + 23).toByte() }375 val attackerKeys = deriveSyncIdentityKeypair(root, "attacker-id")376377 val phoneJob = async {378 try {379 runRelayAdoptionInitiatorSourceSession(380 socket = phoneSocket,381 382 383 invitation = PairingInvitation(384 "wss://relay.example.test",385 "AAECAwQFBgcICQoLDA0ODw",386 VaultStatus.EMPTY,387 ),388 sourceDeps = object : AdoptionSourceV2Deps {389 override suspend fun rotateSourceIdentity() = sourceIdentity390 override fun hasDeviceIdentityCollision(device: PairedDevice) = false391 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true392 override suspend fun completePendingIdentity(device: PairedDevice) = true393 override fun snapshotCapable() = true394395 override val sourceIdentity =396 identity("phone", deriveSyncIdentityKeypair(root, "phone-id").publicKey)397 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)398 override suspend fun vaultId() = computeVaultId(root)399 override suspend fun onConsent(request: AdoptionFrame.Request) = true400 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true401 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit402 },403 confirm = { _, _ -> true },404 )405 "completed"406 } catch (expected: IllegalArgumentException) {407 expected.message408 }409 }410411 mitmSocket.receive() 412 mitmSocket.send(413 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")414 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(attackerKeys.publicKey))415 .toString().toByteArray(),416 )417 val attackerJob = async { attackerReceivesGrant(mitmSocket, attackerKeys) }418419 assertEquals(420 "pairing_invitation_missing_static_key",421 withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() },422 )423 attackerJob.cancel()424 }425426 @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {427 val (phoneSocket, responderSocket) = socketPair()428 val token = "AAECAwQFBgcICQoLDA0ODw"429 val root = ByteArray(32) { (it + 11).toByte() }430 val phoneKeys = deriveSyncIdentityKeypair(root, "phone-id")431 val responderKeys = deriveSyncIdentityKeypair(root, "responder-id")432 var rootRequested = false433 var upserted = false434435 val phoneJob = async {436 runRelayAdoptionInitiatorSourceSession(437 socket = phoneSocket,438 invitation = PairingInvitation(439 "wss://relay.example.test",440 token,441 VaultStatus.EMPTY,442 Rendezvous.bytesToHex(responderKeys.publicKey),443 ),444 sourceDeps = object : AdoptionSourceV2Deps {445 override suspend fun rotateSourceIdentity() = sourceIdentity446 override fun hasDeviceIdentityCollision(device: PairedDevice) = false447 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true448 override suspend fun completePendingIdentity(device: PairedDevice) = true449 override fun snapshotCapable() = true450451 override val sourceIdentity = identity("phone", phoneKeys.publicKey)452 override suspend fun rootKeyHex(): String {453 rootRequested = true454 return Rendezvous.bytesToHex(root)455 }456 override suspend fun vaultId() = computeVaultId(root)457 override suspend fun onConsent(request: AdoptionFrame.Request) = true458 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true459 override suspend fun upsertPairedDevice(device: PairedDevice) {460 upserted = true461 }462 },463 confirm = { _, _ -> false },464 )465 }466467 468 responderSocket.receive() 469 responderSocket.send(470 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")471 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))472 .toString().toByteArray(),473 )474 val noise = noiseNKResponder(475 responderKeys.secretKey,476 responderKeys.publicKey,477 responderSocket::send,478 responderSocket::receive,479 )480 try {481 val channel = object : PairingTransport {482 override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))483 override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())484 override suspend fun close() = responderSocket.close()485 }486 487 488 sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))489 } finally {490 noise.close()491 }492493 assertNull(phoneJob.await())494 assertFalse("root key must stay on this device until SAS matches", rootRequested)495 assertFalse("paired-device store must not be touched on rejection", upserted)496 }497}498499500501502503504505506class RelayAdoptionReceiverDispatchTest {507508 @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {509 val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry)510 val empty = createPairingInvitation(511 "wss://relay.example.test",512 "AAECAwQFBgcICQoLDA0ODw",513 VaultStatus.EMPTY,514 )515 try {516 receiver.adopt(517 encodedInvitation = empty,518 deps = object : AdoptionReceiverV2Deps {519 override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")520 override suspend fun rotateLocalIdentity() = localIdentity()521 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}522 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false523524 override val deviceName = "phone"525 override val deviceType = DeviceType.ANDROID526 override fun generateDeviceId() = "x"527 override suspend fun confirmSas() = true528 override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"529 override suspend fun deriveLocalIdentity() = error("unused")530 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit531 override fun onError(error: String) = Unit532 },533 confirm = { true },534 )535 fail("adopt must reject empty invitations")536 } catch (expected: IllegalArgumentException) {537 assertTrue(538 "expected vaultStatus in the error, got: ${expected.message}",539 expected.message!!.contains("EMPTY", ignoreCase = true),540 )541 }542 }543544 @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {545 val receiver = RelayAdoptionReceiver(FakeSyncGeneration(), EmptyRegistry)546 val populated = createPairingInvitation(547 "wss://relay.example.test",548 "AAECAwQFBgcICQoLDA0ODw",549 VaultStatus.POPULATED,550 )551 try {552 receiver.grantFromInvitation(553 encodedInvitation = populated,554 sourceDeps = object : AdoptionSourceV2Deps {555 override suspend fun rotateSourceIdentity() = sourceIdentity556 override fun hasDeviceIdentityCollision(device: PairedDevice) = false557 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true558 override suspend fun completePendingIdentity(device: PairedDevice) = true559 override fun snapshotCapable() = true560561 override val sourceIdentity = LocalDeviceIdentity(562 "phone", "phone", DeviceType.ANDROID,563 "ab".repeat(32), "cd".repeat(32),564 )565 override suspend fun rootKeyHex() = "ab".repeat(32)566 override suspend fun vaultId() = "cd".repeat(32)567 override suspend fun onConsent(request: AdoptionFrame.Request) = true568 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true569 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit570 },571 confirm = { _, _ -> true },572 )573 fail("grantFromInvitation must reject populated invitations")574 } catch (expected: IllegalArgumentException) {575 assertTrue(576 "expected vaultStatus in the error, got: ${expected.message}",577 expected.message!!.contains("POPULATED", ignoreCase = true),578 )579 }580 }581}