1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertTrue10import org.junit.Assert.assertNull11import org.junit.Test12import kotlin.system.measureTimeMillis1314151617181920212223242526272829303132class RelayAdoptionReceiverTest {33 private class MemoryRelaySocket(34 private val inbound: Channel<ByteArray>,35 private val outbound: Channel<ByteArray>,36 ) : RelayBinarySocket {37 override fun send(frame: ByteArray) {38 check(outbound.trySend(frame.copyOf()).isSuccess)39 }4041 override suspend fun receive(): ByteArray = inbound.receive()42 override fun close(code: Int, reason: String) {43 outbound.close()44 }4546 override fun closeAfterQueuedFrames(code: Int, reason: String) {47 outbound.close()48 }49 }5051 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {52 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)53 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)54 return MemoryRelaySocket(rightToLeft, leftToRight) to55 MemoryRelaySocket(leftToRight, rightToLeft)56 }5758 private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity(59 deviceId = "$name-id",60 deviceName = name,61 deviceType = DeviceType.ANDROID,62 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),63 signingPublicKeyHex = signingPublicKeyHex,64 wsPort = 49142,65 relayUrl = "wss://relay.example.test",66 )6768 private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {69 val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")70 val tombstones = mapOf("dead-0" to 1L)71 return entries to tombstones72 }7374 @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {75 val (sourceSocket, receiverSocket) = socketPair()76 val token = "AAECAwQFBgcICQoLDA0ODw"77 val relay = "wss://relay.example.test"78 val root = ByteArray(32) { (it + 3).toByte() }79 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")80 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")81 val sourceIdentity = identity("source", sourceKeys.publicKey)82 val receiverIdentity = identity("receiver", receiverKeys.publicKey)83 val (entries, tombstones) = smallSnapshot()84 val duressJson = "{\"duress\":true}"85 var sourceStored: PairedDevice? = null86 var receiverStored: PairedDevice? = null87 var sourceMarkedSynced = false88 var receiverAppliedEntries: Map<String, String>? = null89 var receiverAppliedTombstones: Map<String, Long>? = null90 var receiverAppliedDuress: String? = null91 var receiverMarkedSynced = false9293 val sourceJob = async {94 runRelayAdoptionSourceSession(95 sourceSocket,96 token,97 relay,98 sourceKeys.secretKey,99 sourceKeys.publicKey,100 sourceIdentity,101 object : AdoptionSourceDeps {102 override val sourceIdentity = sourceIdentity103 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)104 override suspend fun vaultId() = computeVaultId(root)105 override suspend fun license() = "PPK-test"106 override suspend fun onConsent(request: AdoptionFrame.Request) = true107 override suspend fun upsertPairedDevice(device: PairedDevice) {108 sourceStored = device109 }110 override suspend fun vaultSnapshot() = smallSnapshot()111 override suspend fun duressSlotJson() = duressJson112 override suspend fun markSynced(deviceId: String) {113 sourceMarkedSynced = true114 }115 },116 confirm = { _, _ -> true },117 )118 }119120 val received = runRelayAdoptionReceiverSession(121 receiverSocket,122 PairingInvitation(123 relay,124 token,125 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),126 ),127 object : AdoptionReceiverDeps {128 override val deviceName = "receiver"129 override val deviceType = DeviceType.ANDROID130 override fun generateDeviceId() = "session-only-request"131 override suspend fun confirmSas() = true132 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =133 computeVaultId(hexToPubkey(rootKeyHex))134 override suspend fun deriveLocalIdentity() = receiverIdentity135 override suspend fun upsertPairedDevice(device: PairedDevice) {136 receiverStored = device137 }138 override suspend fun applySnapshot(139 entries: Map<String, String>,140 tombstones: Map<String, Long>,141 duressSlot: String?,142 ) {143 receiverAppliedEntries = entries144 receiverAppliedTombstones = tombstones145 receiverAppliedDuress = duressSlot146 }147 override suspend fun markSynced(deviceId: String) {148 receiverMarkedSynced = true149 }150 override fun onError(error: String) = throw AssertionError(error)151 },152 confirm = { true },153 )154155 assertNotNull(received)156 assertNotNull(sourceJob.await())157 assertEquals(entries, receiverAppliedEntries)158 assertEquals(tombstones, receiverAppliedTombstones)159 assertEquals(duressJson, receiverAppliedDuress)160 assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)161 assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)162 assertNotNull(receiverStored)163 assertNotNull(sourceStored)164 }165166 @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {167 val (sourceSocket, receiverSocket) = socketPair()168 val token = "AAECAwQFBgcICQoLDA0ODw"169 val relay = "wss://relay.example.test"170 val root = ByteArray(32) { (it + 5).toByte() }171 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")172 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")173 val sourceIdentity = identity("source", sourceKeys.publicKey)174 val receiverIdentity = identity("receiver", receiverKeys.publicKey)175 var sourceStored: PairedDevice? = null176 var receiverStored: PairedDevice? = null177 var receiverApplied = false178 var receiverMarkedSynced = false179180 val sourceJob = async {181 runRelayAdoptionSourceSession(182 sourceSocket,183 token,184 relay,185 sourceKeys.secretKey,186 sourceKeys.publicKey,187 sourceIdentity,188 object : AdoptionSourceDeps {189 override val sourceIdentity = sourceIdentity190 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)191 override suspend fun vaultId() = computeVaultId(root)192 override suspend fun license() = "PPK-test"193 override suspend fun onConsent(request: AdoptionFrame.Request) = true194 override suspend fun upsertPairedDevice(device: PairedDevice) {195 sourceStored = device196 }197 198 override suspend fun vaultSnapshot() = null199 },200 confirm = { _, _ -> true },201 )202 }203204 val received = runRelayAdoptionReceiverSession(205 receiverSocket,206 PairingInvitation(207 relay,208 token,209 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),210 ),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() = true216 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =217 computeVaultId(hexToPubkey(rootKeyHex))218 override suspend fun deriveLocalIdentity() = receiverIdentity219 override suspend fun upsertPairedDevice(device: PairedDevice) {220 receiverStored = device221 }222 override suspend fun applySnapshot(223 entries: Map<String, String>,224 tombstones: Map<String, Long>,225 duressSlot: String?,226 ) {227 receiverApplied = true228 }229 override suspend fun markSynced(deviceId: String) {230 receiverMarkedSynced = true231 }232 override fun onError(error: String) = throw AssertionError(error)233 },234 confirm = { true },235 )236237 assertNotNull(received)238 assertNotNull(sourceJob.await())239 assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)240 assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)241 assertNotNull(receiverStored)242 assertNotNull(sourceStored)243 }244245 @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {246 val (sourceSocket, receiverSocket) = socketPair()247 val token = "AAECAwQFBgcICQoLDA0ODw"248 val relay = "wss://relay.example.test"249 val root = ByteArray(32) { (it + 7).toByte() }250 val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")251 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")252 val sourceIdentity = identity("old-source", sourceKeys.publicKey)253 val receiverIdentity = identity("receiver", receiverKeys.publicKey)254 var receiverApplied = false255256 val sourceJob = async {257 sourceSocket.receive()258 sourceSocket.send(259 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")260 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))261 .toString().toByteArray(),262 )263 val noise = noiseNKResponder(264 sourceKeys.secretKey,265 sourceKeys.publicKey,266 sourceSocket::send,267 sourceSocket::receive,268 )269 try {270 val channel = object : PairingTransport {271 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))272 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())273 override suspend fun close() = sourceSocket.close()274 }275 assertTrue(receiveFrame(channel) is AdoptionFrame.Request)276 assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)277 sendFrame(channel, AdoptionFrame.Grant(278 rootKeyHex = Rendezvous.bytesToHex(root),279 vaultId = computeVaultId(root),280 license = "PPK-test",281 source = AdoptionFrame.Source(282 deviceId = sourceIdentity.deviceId,283 deviceName = sourceIdentity.deviceName,284 deviceType = sourceIdentity.deviceType.wireValue,285 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,286 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,287 ),288 snapshotCapable = false,289 ))290 assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)291 sendFrame(channel, AdoptionFrame.Complete)292 assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)293 } finally {294 noise.close()295 }296 }297298 var received: PairedDevice? = null299 val elapsedMs = measureTimeMillis {300 received = runRelayAdoptionReceiverSession(301 receiverSocket,302 PairingInvitation(303 relay,304 token,305 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),306 ),307 object : AdoptionReceiverDeps {308 override val deviceName = "receiver"309 override val deviceType = DeviceType.ANDROID310 override fun generateDeviceId() = "session-only-request"311 override suspend fun confirmSas() = true312 override suspend fun createVaultFromRootKey(rootKeyHex: String) =313 computeVaultId(hexToPubkey(rootKeyHex))314 override suspend fun deriveLocalIdentity() = receiverIdentity315 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit316 override suspend fun applySnapshot(317 entries: Map<String, String>,318 tombstones: Map<String, Long>,319 duressSlot: String?,320 ) {321 receiverApplied = true322 }323 override fun onError(error: String) = throw AssertionError(error)324 },325 confirm = { true },326 )327 }328329 assertNotNull(received)330 sourceJob.await()331 assertFalse(receiverApplied)332 assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)333 }334335 @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {336 337 338 339 340 341 342 val (sourceSocket, receiverSocket) = socketPair()343 val token = "AAECAwQFBgcICQoLDA0ODw"344 val relay = "wss://relay.example.test"345 val root = ByteArray(32) { (it + 13).toByte() }346 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")347 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")348 val sourceSigning = deriveSyncSigningKeypair(root, "source-id")349 val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))350 val receiverIdentity = identity("receiver", receiverKeys.publicKey)351352 353 354 val manifest = buildSignedDeviceManifest(355 issuerDeviceId = "source-id",356 devices = listOf(357 ManifestDeviceEntry(358 deviceId = "peer-b",359 deviceName = "Pool B",360 deviceType = "extension",361 longTermPublicKeyHex = "11".repeat(32),362 signingPublicKeyHex = "22".repeat(32),363 relayUrl = "wss://relay.example.test",364 pairedAt = 1L,365 ),366 ManifestDeviceEntry(367 deviceId = "peer-c",368 deviceName = "Pool C",369 deviceType = "android",370 longTermPublicKeyHex = "aa".repeat(32),371 signingPublicKeyHex = "bb".repeat(32),372 relayUrl = "wss://relay.example.test",373 pairedAt = 2L,374 ),375 ),376 signingPrivateKey = sourceSigning.privateKey,377 )378379 380 381 382 val receiverRegistry = mutableMapOf<String, PairedDevice>()383 val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()384 val receiverRegistryStore = object : PairedDeviceRegistry {385 override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()386 override fun get(id: String): PairedDevice? = receiverRegistry[id]387 override fun getByPubkey(pubkeyHex: String): PairedDevice? =388 receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }389 override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }390 override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }391 override fun listRevocations(): List<DeviceRevocation> = emptyList()392 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {393 batch.removals.forEach { receiverRegistry.remove(it) }394 batch.upserts.forEach { receiverRegistry[it.deviceId] = it }395 return true396 }397 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()398 override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false399 override fun clearRevocation(deviceId: String) {}400 }401 val receiverPendingStore = object : PendingVouchedRegistry {402 override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()403 override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }404 override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }405 override fun removeBatch(ids: Set<String>): Boolean {406 receiverPending.removeAll { it.deviceId in ids }407 return true408 }409 override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true410 }411 var receiverAppliedManifest: SignedDeviceManifest? = null412413 val sourceJob = async {414 runRelayAdoptionSourceSession(415 sourceSocket,416 token,417 relay,418 sourceKeys.secretKey,419 sourceKeys.publicKey,420 sourceIdentity,421 object : AdoptionSourceDeps {422 override val sourceIdentity = sourceIdentity423 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)424 override suspend fun vaultId() = computeVaultId(root)425 override suspend fun license() = "PPK-test"426 override suspend fun onConsent(request: AdoptionFrame.Request) = true427 override suspend fun upsertPairedDevice(device: PairedDevice) {}428 override suspend fun vaultSnapshot() = smallSnapshot()429 override suspend fun duressSlotJson() = null430 override suspend fun outgoingDeviceManifest() = manifest431 override suspend fun markSynced(deviceId: String) {}432 },433 confirm = { _, _ -> true },434 )435 }436437 val received = runRelayAdoptionReceiverSession(438 receiverSocket,439 PairingInvitation(440 relay,441 token,442 noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),443 ),444 object : AdoptionReceiverDeps {445 override val deviceName = "receiver"446 override val deviceType = DeviceType.ANDROID447 override fun generateDeviceId() = "session-only-request"448 override suspend fun confirmSas() = true449 override suspend fun createVaultFromRootKey(rootKeyHex: String) =450 computeVaultId(hexToPubkey(rootKeyHex))451 override suspend fun deriveLocalIdentity() = receiverIdentity452 override suspend fun upsertPairedDevice(device: PairedDevice) {453 receiverRegistry[device.deviceId] = device454 }455 override suspend fun applySnapshot(456 entries: Map<String, String>,457 tombstones: Map<String, Long>,458 duressSlot: String?,459 ) {}460 override suspend fun applyIncomingDeviceManifest(461 manifest: SignedDeviceManifest,462 authenticatedRemoteStaticPublicKey: ByteArray,463 ) {464 receiverAppliedManifest = manifest465 val adapter = VaultSyncAdapter(466 repo = createReceiverVault(root),467 ourDeviceId = "receiver-id",468 pairedDevices = receiverRegistryStore,469 pendingVouched = receiverPendingStore,470 )471 adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)472 }473 override suspend fun markSynced(deviceId: String) {}474 override fun onError(error: String) = throw AssertionError(error)475 },476 confirm = { true },477 )478479 assertNotNull(received)480 sourceJob.await()481 assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)482 483 484 val deviceIds = receiverRegistry.keys.toSet()485 assertEquals(486 "receiver should see the source + both vouched peers",487 setOf("source-id", "peer-b", "peer-c"),488 deviceIds,489 )490 491 assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)492 assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)493 494 assertTrue("pending vouched should be cleared", receiverPending.isEmpty())495 }496497 private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {498 val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())499 repo.createVault(root.joinToString("") { "%02x".format(it) })500 return repo501 }502503 private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {504 private var slotA: com.peachpasswords.vault.StoredVault? = null505 private var slotB: String? = null506 private var decoy: com.peachpasswords.vault.StoredVault? = null507 private var ver: Long = 0508 private var settings = com.peachpasswords.vault.VaultSettings()509 override fun loadSlotA() = slotA510 override fun hasVault() = slotA != null511 override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }512 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }513 override fun loadDecoySlot() = decoy514 override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }515 override fun hasDecoySlot() = decoy != null516 override fun clearDecoySlot() { decoy = null }517 override fun version() = ver518 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }519 override fun loadSettings() = settings520 override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }521 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }522 }523}