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(relay, token),123 object : AdoptionReceiverDeps {124 override val deviceName = "receiver"125 override val deviceType = DeviceType.ANDROID126 override fun generateDeviceId() = "session-only-request"127 override suspend fun confirmSas() = true128 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =129 computeVaultId(hexToPubkey(rootKeyHex))130 override suspend fun deriveLocalIdentity() = receiverIdentity131 override suspend fun upsertPairedDevice(device: PairedDevice) {132 receiverStored = device133 }134 override suspend fun applySnapshot(135 entries: Map<String, String>,136 tombstones: Map<String, Long>,137 duressSlot: String?,138 ) {139 receiverAppliedEntries = entries140 receiverAppliedTombstones = tombstones141 receiverAppliedDuress = duressSlot142 }143 override suspend fun markSynced(deviceId: String) {144 receiverMarkedSynced = true145 }146 override fun onError(error: String) = throw AssertionError(error)147 },148 confirm = { true },149 )150151 assertNotNull(received)152 assertNotNull(sourceJob.await())153 assertEquals(entries, receiverAppliedEntries)154 assertEquals(tombstones, receiverAppliedTombstones)155 assertEquals(duressJson, receiverAppliedDuress)156 assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)157 assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)158 assertNotNull(receiverStored)159 assertNotNull(sourceStored)160 }161162 @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {163 val (sourceSocket, receiverSocket) = socketPair()164 val token = "AAECAwQFBgcICQoLDA0ODw"165 val relay = "wss://relay.example.test"166 val root = ByteArray(32) { (it + 5).toByte() }167 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")168 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")169 val sourceIdentity = identity("source", sourceKeys.publicKey)170 val receiverIdentity = identity("receiver", receiverKeys.publicKey)171 var sourceStored: PairedDevice? = null172 var receiverStored: PairedDevice? = null173 var receiverApplied = false174 var receiverMarkedSynced = false175176 val sourceJob = async {177 runRelayAdoptionSourceSession(178 sourceSocket,179 token,180 relay,181 sourceKeys.secretKey,182 sourceKeys.publicKey,183 sourceIdentity,184 object : AdoptionSourceDeps {185 override val sourceIdentity = sourceIdentity186 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)187 override suspend fun vaultId() = computeVaultId(root)188 override suspend fun license() = "PPK-test"189 override suspend fun onConsent(request: AdoptionFrame.Request) = true190 override suspend fun upsertPairedDevice(device: PairedDevice) {191 sourceStored = device192 }193 194 override suspend fun vaultSnapshot() = null195 },196 confirm = { _, _ -> true },197 )198 }199200 val received = runRelayAdoptionReceiverSession(201 receiverSocket,202 PairingInvitation(relay, token),203 object : AdoptionReceiverDeps {204 override val deviceName = "receiver"205 override val deviceType = DeviceType.ANDROID206 override fun generateDeviceId() = "session-only-request"207 override suspend fun confirmSas() = true208 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =209 computeVaultId(hexToPubkey(rootKeyHex))210 override suspend fun deriveLocalIdentity() = receiverIdentity211 override suspend fun upsertPairedDevice(device: PairedDevice) {212 receiverStored = device213 }214 override suspend fun applySnapshot(215 entries: Map<String, String>,216 tombstones: Map<String, Long>,217 duressSlot: String?,218 ) {219 receiverApplied = true220 }221 override suspend fun markSynced(deviceId: String) {222 receiverMarkedSynced = true223 }224 override fun onError(error: String) = throw AssertionError(error)225 },226 confirm = { true },227 )228229 assertNotNull(received)230 assertNotNull(sourceJob.await())231 assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)232 assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)233 assertNotNull(receiverStored)234 assertNotNull(sourceStored)235 }236237 @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {238 val (sourceSocket, receiverSocket) = socketPair()239 val token = "AAECAwQFBgcICQoLDA0ODw"240 val relay = "wss://relay.example.test"241 val root = ByteArray(32) { (it + 7).toByte() }242 val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")243 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")244 val sourceIdentity = identity("old-source", sourceKeys.publicKey)245 val receiverIdentity = identity("receiver", receiverKeys.publicKey)246 var receiverApplied = false247248 val sourceJob = async {249 sourceSocket.receive()250 sourceSocket.send(251 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")252 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))253 .toString().toByteArray(),254 )255 val noise = noiseNKResponder(256 sourceKeys.secretKey,257 sourceKeys.publicKey,258 sourceSocket::send,259 sourceSocket::receive,260 )261 try {262 val channel = object : PairingTransport {263 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))264 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())265 override suspend fun close() = sourceSocket.close()266 }267 assertTrue(receiveFrame(channel) is AdoptionFrame.Request)268 assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)269 sendFrame(channel, AdoptionFrame.Grant(270 rootKeyHex = Rendezvous.bytesToHex(root),271 vaultId = computeVaultId(root),272 license = "PPK-test",273 source = AdoptionFrame.Source(274 deviceId = sourceIdentity.deviceId,275 deviceName = sourceIdentity.deviceName,276 deviceType = sourceIdentity.deviceType.wireValue,277 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,278 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,279 ),280 snapshotCapable = false,281 ))282 assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)283 sendFrame(channel, AdoptionFrame.Complete)284 assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)285 } finally {286 noise.close()287 }288 }289290 var received: PairedDevice? = null291 val elapsedMs = measureTimeMillis {292 received = runRelayAdoptionReceiverSession(293 receiverSocket,294 PairingInvitation(relay, token),295 object : AdoptionReceiverDeps {296 override val deviceName = "receiver"297 override val deviceType = DeviceType.ANDROID298 override fun generateDeviceId() = "session-only-request"299 override suspend fun confirmSas() = true300 override suspend fun createVaultFromRootKey(rootKeyHex: String) =301 computeVaultId(hexToPubkey(rootKeyHex))302 override suspend fun deriveLocalIdentity() = receiverIdentity303 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit304 override suspend fun applySnapshot(305 entries: Map<String, String>,306 tombstones: Map<String, Long>,307 duressSlot: String?,308 ) {309 receiverApplied = true310 }311 override fun onError(error: String) = throw AssertionError(error)312 },313 confirm = { true },314 )315 }316317 assertNotNull(received)318 sourceJob.await()319 assertFalse(receiverApplied)320 assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)321 }322323 @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {324 325 326 327 328 329 330 val (sourceSocket, receiverSocket) = socketPair()331 val token = "AAECAwQFBgcICQoLDA0ODw"332 val relay = "wss://relay.example.test"333 val root = ByteArray(32) { (it + 13).toByte() }334 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")335 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")336 val sourceSigning = deriveSyncSigningKeypair(root, "source-id")337 val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))338 val receiverIdentity = identity("receiver", receiverKeys.publicKey)339340 341 342 val manifest = buildSignedDeviceManifest(343 issuerDeviceId = "source-id",344 devices = listOf(345 ManifestDeviceEntry(346 deviceId = "peer-b",347 deviceName = "Pool B",348 deviceType = "extension",349 longTermPublicKeyHex = "11".repeat(32),350 signingPublicKeyHex = "22".repeat(32),351 relayUrl = "wss://relay.example.test",352 pairedAt = 1L,353 ),354 ManifestDeviceEntry(355 deviceId = "peer-c",356 deviceName = "Pool C",357 deviceType = "android",358 longTermPublicKeyHex = "aa".repeat(32),359 signingPublicKeyHex = "bb".repeat(32),360 relayUrl = "wss://relay.example.test",361 pairedAt = 2L,362 ),363 ),364 signingPrivateKey = sourceSigning.privateKey,365 )366367 368 369 370 val receiverRegistry = mutableMapOf<String, PairedDevice>()371 val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()372 val receiverRegistryStore = object : PairedDeviceRegistry {373 override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()374 override fun get(id: String): PairedDevice? = receiverRegistry[id]375 override fun getByPubkey(pubkeyHex: String): PairedDevice? =376 receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }377 override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }378 override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }379 override fun listRevocations(): List<DeviceRevocation> = emptyList()380 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {381 batch.removals.forEach { receiverRegistry.remove(it) }382 batch.upserts.forEach { receiverRegistry[it.deviceId] = it }383 return true384 }385 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()386 override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false387 override fun clearRevocation(deviceId: String) {}388 }389 val receiverPendingStore = object : PendingVouchedRegistry {390 override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()391 override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }392 override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }393 override fun removeBatch(ids: Set<String>): Boolean {394 receiverPending.removeAll { it.deviceId in ids }395 return true396 }397 override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true398 }399 var receiverAppliedManifest: SignedDeviceManifest? = null400401 val sourceJob = async {402 runRelayAdoptionSourceSession(403 sourceSocket,404 token,405 relay,406 sourceKeys.secretKey,407 sourceKeys.publicKey,408 sourceIdentity,409 object : AdoptionSourceDeps {410 override val sourceIdentity = sourceIdentity411 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)412 override suspend fun vaultId() = computeVaultId(root)413 override suspend fun license() = "PPK-test"414 override suspend fun onConsent(request: AdoptionFrame.Request) = true415 override suspend fun upsertPairedDevice(device: PairedDevice) {}416 override suspend fun vaultSnapshot() = smallSnapshot()417 override suspend fun duressSlotJson() = null418 override suspend fun outgoingDeviceManifest() = manifest419 override suspend fun markSynced(deviceId: String) {}420 },421 confirm = { _, _ -> true },422 )423 }424425 val received = runRelayAdoptionReceiverSession(426 receiverSocket,427 PairingInvitation(relay, token),428 object : AdoptionReceiverDeps {429 override val deviceName = "receiver"430 override val deviceType = DeviceType.ANDROID431 override fun generateDeviceId() = "session-only-request"432 override suspend fun confirmSas() = true433 override suspend fun createVaultFromRootKey(rootKeyHex: String) =434 computeVaultId(hexToPubkey(rootKeyHex))435 override suspend fun deriveLocalIdentity() = receiverIdentity436 override suspend fun upsertPairedDevice(device: PairedDevice) {437 receiverRegistry[device.deviceId] = device438 }439 override suspend fun applySnapshot(440 entries: Map<String, String>,441 tombstones: Map<String, Long>,442 duressSlot: String?,443 ) {}444 override suspend fun applyIncomingDeviceManifest(445 manifest: SignedDeviceManifest,446 authenticatedRemoteStaticPublicKey: ByteArray,447 ) {448 receiverAppliedManifest = manifest449 val adapter = VaultSyncAdapter(450 repo = createReceiverVault(root),451 ourDeviceId = "receiver-id",452 pairedDevices = receiverRegistryStore,453 pendingVouched = receiverPendingStore,454 )455 adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)456 }457 override suspend fun markSynced(deviceId: String) {}458 override fun onError(error: String) = throw AssertionError(error)459 },460 confirm = { true },461 )462463 assertNotNull(received)464 sourceJob.await()465 assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)466 467 468 val deviceIds = receiverRegistry.keys.toSet()469 assertEquals(470 "receiver should see the source + both vouched peers",471 setOf("source-id", "peer-b", "peer-c"),472 deviceIds,473 )474 475 assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)476 assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)477 478 assertTrue("pending vouched should be cleared", receiverPending.isEmpty())479 }480481 private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {482 val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())483 repo.createVault(root.joinToString("") { "%02x".format(it) })484 return repo485 }486487 private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {488 private var slotA: com.peachpasswords.vault.StoredVault? = null489 private var slotB: String? = null490 private var decoy: com.peachpasswords.vault.StoredVault? = null491 private var ver: Long = 0492 private var settings = com.peachpasswords.vault.VaultSettings()493 override fun loadSlotA() = slotA494 override fun hasVault() = slotA != null495 override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }496 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }497 override fun loadDecoySlot() = decoy498 override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }499 override fun hasDecoySlot() = decoy != null500 override fun clearDecoySlot() { decoy = null }501 override fun version() = ver502 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }503 override fun loadSettings() = settings504 override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }505 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }506 }507}