1package com.peachpasswords.sync23import java.io.BufferedReader4import kotlinx.serialization.json.Json5import java.io.BufferedWriter6import java.io.File7import java.io.InputStreamReader8import java.io.OutputStreamWriter9import java.net.InetSocketAddress10import java.net.ServerSocket11import java.net.Socket12import java.util.Base6413import kotlin.system.exitProcess14import kotlinx.coroutines.runBlocking15import org.junit.Assert.assertEquals16import org.junit.Assert.assertNotNull17import org.junit.Test18192021222324252627282930class CrossClientSyncProcessTest {31 @Test32 fun exchange_adoption_v2_with_browser_process() {33 val role = configured("PEACH_ADOPTION_HARNESS_ROLE") ?: return34 val portFile = File(requireConfigured("PEACH_SYNC_HARNESS_PORT_FILE"))35 val transcriptFile = File(requireConfigured("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE"))36 val raw = if (role == "source") {37 ServerSocket(0).also { portFile.writeText(it.localPort.toString()) }38 .accept().also { portFile.delete() }39 } else {40 Socket().also {41 it.connect(InetSocketAddress("127.0.0.1", requireConfigured("PEACH_SYNC_HARNESS_PORT").toInt()), 5_000)42 }43 }44 raw.use { socket ->45 val relay = LineRelaySocket(socket)46 val root = ByteArray(32) { (it + 1).toByte() }47 val token = "AAECAwQFBgcICQoLDA0ODw"48 val keys = localSyncKeypair(randomLocalIdentityCredentials("android-source"))49 val identity = harnessIdentity("android", keys.publicKey)50 var pending = false51 var rootAfterPending = false52 var promoted = false53 var collisionChecks = 054 var rotations = 055 val adopted = runBlocking {56 if (role == "source") {57 runRelayAdoptionSourceSession(58 relay, token, "wss://relay.example.test", keys.secretKey, keys.publicKey, identity,59 object : AdoptionSourceV2Deps {60 override val sourceIdentity = identity61 override suspend fun rootKeyHex(): String {62 rootAfterPending = pending63 return Rendezvous.bytesToHex(root)64 }65 override suspend fun vaultId() = computeVaultId(root)66 override suspend fun license() = "PPK-cross-client"67 override suspend fun onConsent(request: AdoptionFrame.Request) = true68 override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true69 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean {70 pending = true71 return true72 }73 override suspend fun completePendingIdentity(device: PairedDevice): Boolean {74 promoted = true75 return true76 }77 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit78 override fun hasDeviceIdentityCollision(device: PairedDevice) = collisionChecks++ == 079 override suspend fun rotateSourceIdentity() = identity80 override fun snapshotCapable() = false81 },82 confirm = { _, _ -> true },83 )84 } else {85 runRelayAdoptionReceiverSession(86 relay,87 PairingInvitation("wss://relay.example.test", token, noiseStaticPublicKeyHex = requireConfigured("PEACH_ADOPTION_HARNESS_STATIC")),88 harnessReceiverDeps(identity, root) { rotations += 1 },89 confirm = { true },90 )91 }92 }93 transcriptFile.writeText(94 "{\"status\":\"${if (adopted != null) "completed" else "rejected"}\"," +95 "\"pendingBeforeRoot\":$rootAfterPending,\"promotedAfterApplied\":$promoted,\"rotations\":$rotations}",96 )97 assertNotNull(adopted)98 if (role == "source") {99 assertEquals(true, rootAfterPending)100 assertEquals(true, promoted)101 }102 }103 }104105 private fun harnessIdentity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(106 "$name-id", name, DeviceType.ANDROID, Rendezvous.bytesToHex(publicKey), "cd".repeat(32), 49142,107 "wss://relay.example.test",108 )109110 private fun harnessReceiverDeps(111 identity: LocalDeviceIdentity,112 root: ByteArray,113 onRotate: () -> Unit,114 ) = object : AdoptionReceiverV2Deps {115 override val deviceName = "android"116 override val deviceType = DeviceType.ANDROID117 override fun generateDeviceId() = identity.deviceId118 override fun localIdentity() = identity119 override suspend fun rotateLocalIdentity() = identity.copy(deviceId = "android-rotated").also { onRotate() }120 override suspend fun confirmSas() = true121 override suspend fun validateSource(prepare: AdoptionFrame.Prepare) = Unit122 override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false123 override suspend fun createVaultFromRootKey(rootKeyHex: String) = computeVaultId(hexToPubkey(rootKeyHex))124 override suspend fun deriveLocalIdentity() = identity125 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit126 override fun onError(error: String) = throw AssertionError(error)127 override suspend fun license() = "PPK-cross-client"128 }129130 private class LineRelaySocket(socket: Socket) : RelayBinarySocket {131 private val input = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))132 private val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))133 override fun send(frame: ByteArray) {134 output.write(Base64.getEncoder().encodeToString(frame))135 output.newLine()136 output.flush()137 }138 override suspend fun receive(): ByteArray = Base64.getDecoder().decode(input.readLine() ?: error("cross-client peer closed"))139 override fun close(code: Int, reason: String) = Unit140 }141142 @Test143 fun exchange_production_frames_with_browser_process() {144 val mode = configured("PEACH_SYNC_HARNESS_MODE") ?: return145 val portFile = File(requireConfigured("PEACH_SYNC_HARNESS_PORT_FILE"))146 val transcriptFile = File(requireConfigured("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE"))147 val config = HarnessConfig.read(if (mode == "initiator") "android" else "browser", ::configured)148 val socket = if (mode == "initiator") {149 ServerSocket(0).also {150 portFile.writeText(it.localPort.toString())151 }.accept().also { portFile.delete() }152 } else {153 Socket().also {154 it.connect(InetSocketAddress("127.0.0.1", requireConfigured("PEACH_SYNC_HARNESS_PORT").toInt()), 5_000)155 }156 }157158 socket.use { raw ->159 val input = BufferedReader(InputStreamReader(raw.getInputStream(), Charsets.UTF_8))160 val output = BufferedWriter(OutputStreamWriter(raw.getOutputStream(), Charsets.UTF_8))161 val transcript = mutableListOf<String>()162 val vault = ProcessVault(config)163 164 165 val die: () -> Nothing = {166 transcriptFile.writeText(renderTranscript("DIED", null, vault, transcript))167 exitProcess(7)168 }169 val session = SyncSession(170 remoteStaticPublicKey = ByteArray(32) { 7 },171 send = { bytes ->172 val message = decodeSyncMessage(bytes)173 val wireMessage = if (config.mismatch && message is SyncMessage.Manifest) {174 SyncMessage.Request(emptyList())175 } else {176 message177 }178 transcript += "send:${messageType(wireMessage)}"179 if (config.stopAfter == messageType(wireMessage)) die()180 val padded = SyncPadding.pad(wireMessage.encodeToBytes())181 output.write(Base64.getEncoder().encodeToString(padded))182 output.newLine()183 output.flush()184 },185 receive = {186 val line = input.readLine() ?: error("cross-client peer closed")187 val payload = SyncPadding.unpad(Base64.getDecoder().decode(line))188 transcript += "recv:${messageType(decodeSyncMessage(payload))}"189 payload190 },191 close = {},192 )193 val outcome = runBlocking {194 if (mode == "initiator") {195 runSyncInitiator(session, vault, nowMs = System.currentTimeMillis())196 } else {197 runSyncResponder(session, vault, nowMs = System.currentTimeMillis())198 }199 }200 201 202 203 if (config.drain) {204 runCatching {205 raw.soTimeout = 2_000206 while (input.readLine() != null) Unit207 }208 }209 transcriptFile.writeText(renderTranscript(outcome.status.name, outcome, vault, transcript))210 assertEquals(211 "Android process sync outcome",212 config.expectStatus,213 outcome.status,214 )215 }216 }217218 private fun craftedManifest(config: HarnessConfig): SignedDeviceManifest? =219 config.signedManifestJson?.let {220 json.decodeFromString(SignedDeviceManifest.serializer(), it)221 }222223 224225226227228 private fun manifestVerdict(config: HarnessConfig, manifest: SignedDeviceManifest, nowMs: Long): String {229 val trustedHex = config.trustedPubkeyHex ?: return "not-checked"230 val accepted = verifyDeviceManifest(231 manifest = manifest,232 issuerSigningPublicKey = hexToPubkey(trustedHex),233 resolveIssuerSigningKeys = { setOf(trustedHex.lowercase()) },234 nowMs = nowMs,235 )236 return if (accepted != null) "accepted" else "rejected"237 }238239 private fun renderTranscript(240 status: String,241 outcome: SyncOutcome?,242 vault: ProcessVault,243 transcript: List<String>,244 ): String = buildString {245 append("{\"status\":\"").append(status).append("\"")246 outcome?.error?.let { append(",\"error\":\"").append(it.replace("\"", "\\\"")).append("\"") }247 append(",\"entriesSent\":").append(outcome?.entriesSent ?: 0)248 append(",\"entriesReceived\":").append(outcome?.entriesReceived ?: 0)249 append(",\"applied\":").append(vault.appliedCount)250 append(",\"staged\":").append(vault.stagedCount)251 append(",\"manifestAccepted\":").append(252 vault.manifestVerdict?.let { it == "accepted" } ?: "null",253 )254 append(",\"events\":[")255 transcript.forEachIndexed { index, event ->256 if (index > 0) append(',')257 append('"').append(event).append('"')258 }259 append("]}")260 }261262 private val json = Json { ignoreUnknownKeys = true }263264 private fun configured(name: String): String? =265 System.getenv(name) ?: System.getProperty(name.lowercase().replace('_', '.'))266267 private fun requireConfigured(name: String): String =268 configured(name) ?: error("missing $name")269270 private fun messageType(message: SyncMessage): String = when (message) {271 is SyncMessage.Hello -> "SYNC_HELLO"272 is SyncMessage.Manifest -> "SYNC_MANIFEST"273 is SyncMessage.Request -> "SYNC_REQUEST"274 is SyncMessage.Entries -> "SYNC_ENTRIES"275 is SyncMessage.Tombstones -> "SYNC_TOMBSTONES"276 is SyncMessage.DeviceManifest -> "SYNC_DEVICE_MANIFEST"277 is SyncMessage.Done -> "SYNC_DONE"278 is SyncMessage.Error -> "SYNC_ERROR"279 }280281 282 private class HarnessConfig(283 val id: String,284 val parity: String,285 val localVersion: Long,286 val vaultId: String,287 val manifestSupport: Boolean,288 val entryCount: Int,289 val expectStatus: SyncOutcomeStatus,290 val drain: Boolean,291 val mismatch: Boolean,292 val locked: Boolean,293 val stopAfter: String?,294 295296297298299 val signedManifestJson: String?,300 val trustedPubkeyHex: String?,301 ) {302 companion object {303 fun read(id: String, configured: (String) -> String?) = HarnessConfig(304 id = id,305 parity = configured("PEACH_SYNC_HARNESS_MTIME_PARITY") ?: "",306 localVersion = configured("PEACH_SYNC_HARNESS_LOCAL_VERSION")?.toLongOrNull() ?: 7L,307 vaultId = configured("PEACH_SYNC_HARNESS_VAULT_ID") ?: "cross-client-vault",308 manifestSupport = configured("PEACH_SYNC_HARNESS_MANIFEST_VERSION") != "3",309 entryCount = configured("PEACH_SYNC_HARNESS_ENTRY_COUNT")?.toIntOrNull() ?: 1,310 expectStatus = SyncOutcomeStatus.valueOf(311 configured("PEACH_SYNC_HARNESS_EXPECT_STATUS") ?: "COMPLETED",312 ),313 drain = configured("PEACH_SYNC_HARNESS_DRAIN") == "1",314 mismatch = configured("PEACH_SYNC_HARNESS_MISMATCH") == "message-order",315 316 317 318 locked = configured("PEACH_SYNC_HARNESS_LOCKED") == "1",319 stopAfter = configured("PEACH_SYNC_HARNESS_STOP_AFTER")?.takeIf(String::isNotEmpty),320 signedManifestJson = configured("PEACH_SYNC_HARNESS_SIGNED_MANIFEST")?.takeIf(String::isNotEmpty),321 trustedPubkeyHex = configured("PEACH_SYNC_HARNESS_TRUSTED_PUBKEY")?.takeIf(String::isNotEmpty),322 )323 }324 }325326 private inner class ProcessVault(private val config: HarnessConfig) : SyncVaultAdapter {327 private val id = config.id328 329 330 331 332 private val ids = (0 until config.entryCount).map { "shared-entry-$it" }333 private val entries = linkedMapOf<String, Long>().apply {334 ids.forEachIndexed { index, entryId -> put(entryId, mtimeFor(index)) }335 }336 private val blobs = linkedMapOf<String, String>().apply {337 ids.forEachIndexed { index, entryId -> put(entryId, "$id-frame-${index % 8}") }338 }339340 private fun mtimeFor(index: Int): Long {341 if (config.parity.isEmpty()) return 10L342 return if (index % 2 == config.parity.toInt()) 15L else 10L343 }344 private val tombstones = emptyMap<String, Long>()345 private val manifest = SignedDeviceManifest(346 DeviceManifestBody(DEVICE_MANIFEST_VERSION, id, 1_700_000_000_000L, emptyList()),347 "11".repeat(64),348 )349350 351 var manifestVerdict: String? = null352 private set353354 private val nowMs = System.currentTimeMillis()355 private val crafted: SignedDeviceManifest? = craftedManifest(config)356357 358 var appliedCount = 0359 private set360361 362 var stagedCount = 0363 private set364365 private val staged = linkedMapOf<String, String>()366367 override suspend fun computeVaultId() = config.vaultId368 override suspend fun getOurDeviceId() = id369 override suspend fun getLocalVersion() = config.localVersion370 override suspend fun buildEntryManifest() = entries.toMap()371 override suspend fun buildTombstoneManifest() = tombstones372373 override suspend fun getEncryptedEntries(entryIds: List<String>) =374 entryIds.mapNotNull { key -> blobs[key]?.let { key to it } }.toMap()375376 override suspend fun applyEntries(entries: Map<String, String>, remoteManifest: Map<String, Long>) {377 if (config.locked) {378 staged.putAll(entries)379 stagedCount += entries.size380 return381 }382 appliedCount += entries.size383 this.entries.putAll(entries.mapValues { remoteManifest[it.key] ?: 0L })384 blobs.putAll(entries)385 }386387 override suspend fun applyTombstones(tombstones: Map<String, Long>) = Unit388389 override suspend fun buildOutgoingDeviceManifest() =390 if (!config.manifestSupport || config.locked) null else crafted ?: manifest391392 override suspend fun applyIncomingDeviceManifest(393 manifest: SignedDeviceManifest,394 authenticatedRemoteStaticPublicKey: ByteArray,395 ) {396 manifestVerdict = manifestVerdict(config, manifest, nowMs)397 }398399 override val supportsDeviceManifest = config.manifestSupport && !config.locked400 }401}