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 3132333435 private class QueuedFinalFrameSocket(36 private val delegate: RelayBinarySocket,37 ) : RelayBinarySocket {38 private val queued = mutableListOf<ByteArray>()39 var gracefullyClosed = false40 private set41 var aborted = false42 private set4344 override fun send(frame: ByteArray) {45 queued += frame.copyOf()46 }4748 override suspend fun receive(): ByteArray {49 flush()50 return delegate.receive()51 }5253 override fun close(code: Int, reason: String) {54 aborted = true55 queued.clear()56 delegate.close(code, reason)57 }5859 override fun closeAfterQueuedFrames(code: Int, reason: String) {60 gracefullyClosed = true61 flush()62 delegate.closeAfterQueuedFrames(code, reason)63 }6465 private fun flush() {66 queued.forEach(delegate::send)67 queued.clear()68 }69 }7071 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {72 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)73 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)74 return MemoryRelaySocket(rightToLeft, leftToRight) to75 MemoryRelaySocket(leftToRight, rightToLeft)76 }7778 private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(79 deviceId = "$name-id",80 deviceName = name,81 deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,82 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),83 signingPublicKeyHex = "cd".repeat(32),84 wsPort = 49142,85 relayUrl = "wss://relay.example.test",86 )8788 @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {89 val (sourceSocket, receiverSocket) = socketPair()90 val token = "AAECAwQFBgcICQoLDA0ODw"91 val relay = "wss://relay.example.test"92 val sourceRoot = ByteArray(32) { (it + 1).toByte() }93 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))94 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))95 val sourceIdentity = identity("source", sourceKeys.publicKey)96 val receiverIdentity = identity("receiver", receiverKeys.publicKey)97 var sourceStored: PairedDevice? = null98 var receiverStored: PairedDevice? = null99100 val sourceJob = async {101 runRelayAdoptionSourceSession(102 socket = sourceSocket,103 expectedToken = token,104 relayUrl = relay,105 ourStaticPrivateKey = sourceKeys.secretKey,106 ourStaticPublicKey = sourceKeys.publicKey,107 localIdentity = sourceIdentity,108 sourceDeps = object : AdoptionSourceV2Deps {109 override suspend fun rotateSourceIdentity() = sourceIdentity110 override fun hasDeviceIdentityCollision(device: PairedDevice) = false111 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true112 override suspend fun completePendingIdentity(device: PairedDevice) = true113 override fun snapshotCapable() = true114115 override val sourceIdentity = sourceIdentity116 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)117 override suspend fun vaultId() = computeVaultId(sourceRoot)118 override suspend fun license() = "PPK-test"119 override suspend fun onConsent(request: AdoptionFrame.Request) = true120 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true121 override suspend fun upsertPairedDevice(device: PairedDevice) {122 sourceStored = device123 }124 },125 confirm = { sas, request ->126 assertEquals("receiver", request.deviceName)127 assertEquals(8, sas.length)128 true129 },130 )131 }132133 receiverSocket.send(134 JSONObject().put("type", "PEACH_HELLO").put("token", token)135 .toString().toByteArray(),136 )137 val ack = JSONObject(String(receiverSocket.receive()))138 val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))139 val noise = noiseNKInitiator(140 sourcePublic,141 receiverSocket::send,142 receiverSocket::receive,143 )144 val channel = encryptedChannel(receiverSocket, noise)145 val received = requestAdoption(146 channel,147 object : AdoptionReceiverV2Deps {148 override fun localIdentity() = receiverIdentity149 override suspend fun rotateLocalIdentity() = localIdentity()150 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}151 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false152153 override val deviceName = "receiver"154 override val deviceType = DeviceType.EXTENSION155 override val pairState = AdoptionPairState(156 Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),157 relay,158 )159 override fun generateDeviceId() = "session-request"160 override suspend fun confirmSas() = true161 override suspend fun createVaultFromRootKey(rootKeyHex: String) =162 computeVaultId(hexToPubkey(rootKeyHex))163 override suspend fun deriveLocalIdentity() = receiverIdentity164 override suspend fun upsertPairedDevice(device: PairedDevice) {165 receiverStored = device166 }167 override fun onError(error: String) = throw AssertionError(error)168 },169 )170171 assertNotNull(received)172 assertNotNull(sourceJob.await())173 assertEquals(relay, receiverStored?.relayUrl)174 assertEquals(relay, sourceStored?.relayUrl)175 assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)176 assertEquals(64, receiverStored?.rendezvousSecretHex?.length)177 }178179 @Test fun `source rejection never requests the root`() = runBlocking {180 val (sourceSocket, receiverSocket) = socketPair()181 val token = "AAECAwQFBgcICQoLDA0ODw"182 val root = ByteArray(32) { (it + 7).toByte() }183 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))184 val rootRequested = AtomicBoolean(false)185186 val sourceJob = async {187 runRelayAdoptionSourceSession(188 sourceSocket,189 token,190 "wss://relay.example.test",191 sourceKeys.secretKey,192 sourceKeys.publicKey,193 identity("source", sourceKeys.publicKey),194 object : AdoptionSourceV2Deps {195 override suspend fun rotateSourceIdentity() = sourceIdentity196 override fun hasDeviceIdentityCollision(device: PairedDevice) = false197 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true198 override suspend fun completePendingIdentity(device: PairedDevice) = true199 override fun snapshotCapable() = true200201 override val sourceIdentity = identity("source", sourceKeys.publicKey)202 override suspend fun rootKeyHex(): String {203 rootRequested.set(true)204 return Rendezvous.bytesToHex(root)205 }206 override suspend fun vaultId() = computeVaultId(root)207 override suspend fun onConsent(request: AdoptionFrame.Request) = true208 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true209 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit210 },211 confirm = { _, _ -> false },212 )213 }214 receiverSocket.send(215 JSONObject().put("type", "PEACH_HELLO").put("token", token)216 .toString().toByteArray(),217 )218 val ack = JSONObject(String(receiverSocket.receive()))219 val noise = noiseNKInitiator(220 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),221 receiverSocket::send,222 receiverSocket::receive,223 )224 val channel = encryptedChannel(receiverSocket, noise)225 sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))226227 assertNull(sourceJob.await())228 assertFalse(rootRequested.get())229 }230231 @Test fun `a pre-reset source still runs the v1 transcript`() = runBlocking {232 val (sourceSocket, receiverSocket) = socketPair()233 val token = "AAECAwQFBgcICQoLDA0ODw"234 val root = ByteArray(32) { (it + 9).toByte() }235 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))236 val rootRequested = AtomicBoolean(false)237 val prompted = AtomicBoolean(false)238 val outdated = AtomicBoolean(false)239240 val sourceJob = async {241 sourceSession(242 socket = sourceSocket,243 token = token,244 root = root,245 sourceKeys = sourceKeys,246 rootRequested = rootRequested,247 confirm = { _, _ -> prompted.set(true); false },248 postReset = { false },249 onPeerOutdated = { outdated.set(true) },250 )251 }252 receiverSocket.send(253 JSONObject().put("type", "PEACH_HELLO").put("token", token)254 .toString().toByteArray(),255 )256 val ack = JSONObject(String(receiverSocket.receive()))257 val noise = noiseNKInitiator(258 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),259 receiverSocket::send,260 receiverSocket::receive,261 )262 val channel = encryptedChannel(receiverSocket, noise)263 sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))264265 assertNull(sourceJob.await())266 assertTrue("v1 is still offered before a reset", prompted.get())267 assertFalse("v1 is not an outdated peer before a reset", outdated.get())268 assertFalse("a declined prompt never reads the root", rootRequested.get())269 }270271 @Test fun `a post-reset source refuses v1 before any prompt or grant`() = runBlocking {272 val (sourceSocket, receiverSocket) = socketPair()273 val token = "AAECAwQFBgcICQoLDA0ODw"274 val root = ByteArray(32) { (it + 13).toByte() }275 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))276 val rootRequested = AtomicBoolean(false)277 val prompted = AtomicBoolean(false)278 val outdated = AtomicBoolean(false)279280 val sourceJob = async {281 sourceSession(282 socket = sourceSocket,283 token = token,284 root = root,285 sourceKeys = sourceKeys,286 rootRequested = rootRequested,287 confirm = { _, _ -> prompted.set(true); true },288 postReset = { true },289 onPeerOutdated = { outdated.set(true) },290 )291 }292 receiverSocket.send(293 JSONObject().put("type", "PEACH_HELLO").put("token", token)294 .toString().toByteArray(),295 )296 val ack = JSONObject(String(receiverSocket.receive()))297 val noise = noiseNKInitiator(298 hexToPubkey(ack.getString("ephemeralPublicKeyHex")),299 receiverSocket::send,300 receiverSocket::receive,301 )302 val channel = encryptedChannel(receiverSocket, noise)303 sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))304305 assertNull(sourceJob.await())306 assertTrue("the refusal must be reported as an outdated peer", outdated.get())307 assertFalse("no SAS prompt for a peer that is refused", prompted.get())308 assertFalse("the root must never be read for a refused peer", rootRequested.get())309 }310311 312 private suspend fun sourceSession(313 socket: RelayBinarySocket,314 token: String,315 root: ByteArray,316 sourceKeys: SyncIdentityKeypair,317 rootRequested: AtomicBoolean,318 confirm: suspend (String, AdoptionFrame.Request) -> Boolean,319 postReset: () -> Boolean,320 onPeerOutdated: () -> Unit,321 ): PairedDevice? = runRelayAdoptionSourceSession(322 socket,323 token,324 "wss://relay.example.test",325 sourceKeys.secretKey,326 sourceKeys.publicKey,327 identity("source", sourceKeys.publicKey),328 object : AdoptionSourceV2Deps {329 override suspend fun rotateSourceIdentity() = sourceIdentity330 override fun hasDeviceIdentityCollision(device: PairedDevice) = false331 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true332 override suspend fun completePendingIdentity(device: PairedDevice) = true333 override fun snapshotCapable() = true334335 override val sourceIdentity = identity("source", sourceKeys.publicKey)336 override suspend fun rootKeyHex(): String {337 rootRequested.set(true)338 return Rendezvous.bytesToHex(root)339 }340341 override suspend fun vaultId() = computeVaultId(root)342 override suspend fun onConsent(request: AdoptionFrame.Request) = true343 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true344 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit345 },346 confirm = confirm,347 postReset = postReset,348 onPeerOutdated = onPeerOutdated,349 )350351 @Test fun `receiver session completes real Noise adoption with matching pair state`() = runBlocking {352 val (sourceSocket, receiverSocket) = socketPair()353 val token = "AAECAwQFBgcICQoLDA0ODw"354 val relay = "wss://relay.example.test"355 val root = ByteArray(32) { (it + 3).toByte() }356 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))357 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))358 val sourceIdentity = identity("source", sourceKeys.publicKey)359 val receiverIdentity = identity("receiver", receiverKeys.publicKey)360 var sourceStored: PairedDevice? = null361 var receiverStored: PairedDevice? = null362 var sourceSas = ""363 var receiverSas = ""364365 val sourceJob = async {366 runRelayAdoptionSourceSession(367 sourceSocket,368 token,369 relay,370 sourceKeys.secretKey,371 sourceKeys.publicKey,372 sourceIdentity,373 object : AdoptionSourceV2Deps {374 override suspend fun rotateSourceIdentity() = sourceIdentity375 override fun hasDeviceIdentityCollision(device: PairedDevice) = false376 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true377 override suspend fun completePendingIdentity(device: PairedDevice) = true378 override fun snapshotCapable() = false379380 override val sourceIdentity = sourceIdentity381 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)382 override suspend fun vaultId() = computeVaultId(root)383 override suspend fun license() = "PPK-test"384 override suspend fun onConsent(request: AdoptionFrame.Request) = true385 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true386 override suspend fun upsertPairedDevice(device: PairedDevice) {387 sourceStored = device388 }389 },390 confirm = { sas, _ ->391 sourceSas = sas392 true393 },394 )395 }396397 val queuedReceiverSocket = QueuedFinalFrameSocket(receiverSocket)398 val received = adoptFromRelaySocket(399 socket = queuedReceiverSocket,400 invitation = PairingInvitation(401 relay,402 token,403 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),404 ),405 deps = object : AdoptionReceiverV2Deps {406 override fun localIdentity() = receiverIdentity407 override suspend fun rotateLocalIdentity() = localIdentity()408 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}409 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false410411 override val deviceName = "receiver"412 override val deviceType = DeviceType.ANDROID413 override fun generateDeviceId() = "session-only-request"414 override suspend fun confirmSas() = error("session wrapper owns SAS")415 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {416 assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)417 return computeVaultId(hexToPubkey(rootKeyHex))418 }419 override suspend fun deriveLocalIdentity() = receiverIdentity420 override suspend fun upsertPairedDevice(device: PairedDevice) {421 receiverStored = device422 }423 override fun onError(error: String) = throw AssertionError(error)424 },425 confirm = { sas ->426 receiverSas = sas427 true428 },429 )430431 assertNotNull(received)432 assertNotNull(sourceJob.await())433 assertTrue(queuedReceiverSocket.gracefullyClosed)434 assertFalse(queuedReceiverSocket.aborted)435 assertEquals(sourceSas, receiverSas)436 assertEquals(relay, sourceStored?.relayUrl)437 assertEquals(relay, receiverStored?.relayUrl)438 assertEquals(sourceStored?.rendezvousSecretHex, receiverStored?.rendezvousSecretHex)439 }440441 @Test fun `receiver rejection is encrypted and source never reads root`() = runBlocking {442 val token = "AAECAwQFBgcICQoLDA0ODw"443 val relay = "wss://relay.example.test"444 val root = ByteArray(32) { (it + 9).toByte() }445 val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))446 val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))447 val receiverIdentity = identity("receiver", receiverKeys.publicKey)448 val (sourceSocket, receiverSocket) = socketPair()449 val rootRequested = AtomicBoolean(false)450451 val sourceJob = async {452 runRelayAdoptionSourceSession(453 sourceSocket,454 token,455 relay,456 sourceKeys.secretKey,457 sourceKeys.publicKey,458 identity("source", sourceKeys.publicKey),459 object : AdoptionSourceV2Deps {460 override suspend fun rotateSourceIdentity() = sourceIdentity461 override fun hasDeviceIdentityCollision(device: PairedDevice) = false462 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true463 override suspend fun completePendingIdentity(device: PairedDevice) = true464 override fun snapshotCapable() = true465466 override val sourceIdentity = identity("source", sourceKeys.publicKey)467 override suspend fun rootKeyHex(): String {468 rootRequested.set(true)469 return Rendezvous.bytesToHex(root)470 }471 override suspend fun vaultId() = computeVaultId(root)472 override suspend fun onConsent(request: AdoptionFrame.Request) = true473 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true474 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit475 },476 confirm = { _, _ -> true },477 )478 }479 var created = false480 val received = runRelayAdoptionReceiverSession(481 receiverSocket,482 PairingInvitation(483 relay,484 token,485 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),486 ),487 object : AdoptionReceiverV2Deps {488 override fun localIdentity() = receiverIdentity489 override suspend fun rotateLocalIdentity() = localIdentity()490 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}491 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false492493 override val deviceName = "receiver"494 override val deviceType = DeviceType.ANDROID495 override fun generateDeviceId() = "session-only-request"496 override suspend fun confirmSas() = error("session wrapper owns SAS")497 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {498 created = true499 return computeVaultId(hexToPubkey(rootKeyHex))500 }501 override suspend fun deriveLocalIdentity() =502 identity("receiver", sourceKeys.publicKey)503 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit504 override fun onError(error: String) {505 assertEquals("ADOPT_SAS_REJECTED", error)506 }507 },508 confirm = { false },509 )510511 assertNull(received)512 assertNull(sourceJob.await())513 assertFalse(rootRequested.get())514 assertFalse(created)515 }516517 @Test fun `malformed receiver ACK fails before any vault mutation`() = runBlocking {518 val (unusedSource, receiverSocket) = socketPair()519 unusedSource.send(520 JSONObject()521 .put("type", "PEACH_HELLO_ACK")522 .put("ephemeralPublicKeyHex", "ab")523 .toString()524 .toByteArray(),525 )526 var created = false527 val result = runCatching {528 runRelayAdoptionReceiverSession(529 receiverSocket,530 PairingInvitation(531 "wss://relay.example.test",532 "AAECAwQFBgcICQoLDA0ODw",533 ),534 object : AdoptionReceiverV2Deps {535 override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")536 override suspend fun rotateLocalIdentity() = localIdentity()537 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}538 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false539540 override val deviceName = "receiver"541 override val deviceType = DeviceType.ANDROID542 override fun generateDeviceId() = "request"543 override suspend fun confirmSas() = false544 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {545 created = true546 return ""547 }548 override suspend fun deriveLocalIdentity() =549 error("must not derive identity")550 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit551 override fun onError(error: String) = Unit552 },553 confirm = { true },554 )555 }556 assertTrue(result.isFailure)557 assertFalse(created)558 }559560 private fun encryptedChannel(561 socket: RelayBinarySocket,562 noise: NoiseNKSession,563 ): PairingTransport = object : PairingTransport {564 override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))565 override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())566 override suspend fun close() = socket.close()567 }568}