package com.peachpasswords.sync import java.io.BufferedReader import kotlinx.serialization.json.Json import java.io.BufferedWriter import java.io.File import java.io.InputStreamReader import java.io.OutputStreamWriter import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.util.Base64 import kotlin.system.exitProcess import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Test /** * Process endpoint for scripts/check-cross-client-sync.mjs. The socket carries * one base64-encoded, production-padded SyncMessage frame per line; the * transcript is kept separately so the parent process can assert every ordered * wire type. * * Scenario shape is supplied entirely by the parent — this endpoint reads knobs * and does not know what a "scenario" is. Keeping the semantics in the harness * is what lets both clients be configured identically and lets a reviewer read * the matrix in one place. */ class CrossClientSyncProcessTest { @Test fun exchange_adoption_v2_with_browser_process() { val role = configured("PEACH_ADOPTION_HARNESS_ROLE") ?: return val portFile = File(requireConfigured("PEACH_SYNC_HARNESS_PORT_FILE")) val transcriptFile = File(requireConfigured("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE")) val raw = if (role == "source") { ServerSocket(0).also { portFile.writeText(it.localPort.toString()) } .accept().also { portFile.delete() } } else { Socket().also { it.connect(InetSocketAddress("127.0.0.1", requireConfigured("PEACH_SYNC_HARNESS_PORT").toInt()), 5_000) } } raw.use { socket -> val relay = LineRelaySocket(socket) val root = ByteArray(32) { (it + 1).toByte() } val token = "AAECAwQFBgcICQoLDA0ODw" val keys = localSyncKeypair(randomLocalIdentityCredentials("android-source")) val identity = harnessIdentity("android", keys.publicKey) var pending = false var rootAfterPending = false var promoted = false var collisionChecks = 0 var rotations = 0 val adopted = runBlocking { if (role == "source") { runRelayAdoptionSourceSession( relay, token, "wss://relay.example.test", keys.secretKey, keys.publicKey, identity, object : AdoptionSourceV2Deps { override val sourceIdentity = identity override suspend fun rootKeyHex(): String { rootAfterPending = pending return Rendezvous.bytesToHex(root) } override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-cross-client" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean { pending = true return true } override suspend fun completePendingIdentity(device: PairedDevice): Boolean { promoted = true return true } override suspend fun upsertPairedDevice(device: PairedDevice) = Unit override fun hasDeviceIdentityCollision(device: PairedDevice) = collisionChecks++ == 0 override suspend fun rotateSourceIdentity() = identity override fun snapshotCapable() = false }, confirm = { _, _ -> true }, ) } else { runRelayAdoptionReceiverSession( relay, PairingInvitation("wss://relay.example.test", token, noiseStaticPublicKeyHex = requireConfigured("PEACH_ADOPTION_HARNESS_STATIC")), harnessReceiverDeps(identity, root) { rotations += 1 }, confirm = { true }, ) } } transcriptFile.writeText( "{\"status\":\"${if (adopted != null) "completed" else "rejected"}\"," + "\"pendingBeforeRoot\":$rootAfterPending,\"promotedAfterApplied\":$promoted,\"rotations\":$rotations}", ) assertNotNull(adopted) if (role == "source") { assertEquals(true, rootAfterPending) assertEquals(true, promoted) } } } private fun harnessIdentity(name: String, publicKey: ByteArray) = LocalDeviceIdentity( "$name-id", name, DeviceType.ANDROID, Rendezvous.bytesToHex(publicKey), "cd".repeat(32), 49142, "wss://relay.example.test", ) private fun harnessReceiverDeps( identity: LocalDeviceIdentity, root: ByteArray, onRotate: () -> Unit, ) = object : AdoptionReceiverV2Deps { override val deviceName = "android" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = identity.deviceId override fun localIdentity() = identity override suspend fun rotateLocalIdentity() = identity.copy(deviceId = "android-rotated").also { onRotate() } override suspend fun confirmSas() = true override suspend fun validateSource(prepare: AdoptionFrame.Prepare) = Unit override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override suspend fun createVaultFromRootKey(rootKeyHex: String) = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = identity override suspend fun upsertPairedDevice(device: PairedDevice) = Unit override fun onError(error: String) = throw AssertionError(error) override suspend fun license() = "PPK-cross-client" } private class LineRelaySocket(socket: Socket) : RelayBinarySocket { private val input = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8)) private val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8)) override fun send(frame: ByteArray) { output.write(Base64.getEncoder().encodeToString(frame)) output.newLine() output.flush() } override suspend fun receive(): ByteArray = Base64.getDecoder().decode(input.readLine() ?: error("cross-client peer closed")) override fun close(code: Int, reason: String) = Unit } @Test fun exchange_production_frames_with_browser_process() { val mode = configured("PEACH_SYNC_HARNESS_MODE") ?: return val portFile = File(requireConfigured("PEACH_SYNC_HARNESS_PORT_FILE")) val transcriptFile = File(requireConfigured("PEACH_SYNC_HARNESS_TRANSCRIPT_FILE")) val config = HarnessConfig.read(if (mode == "initiator") "android" else "browser", ::configured) val socket = if (mode == "initiator") { ServerSocket(0).also { portFile.writeText(it.localPort.toString()) }.accept().also { portFile.delete() } } else { Socket().also { it.connect(InetSocketAddress("127.0.0.1", requireConfigured("PEACH_SYNC_HARNESS_PORT").toInt()), 5_000) } } socket.use { raw -> val input = BufferedReader(InputStreamReader(raw.getInputStream(), Charsets.UTF_8)) val output = BufferedWriter(OutputStreamWriter(raw.getOutputStream(), Charsets.UTF_8)) val transcript = mutableListOf() val vault = ProcessVault(config) // Simulated process death: the transcript is on disk before the // process goes, so the parent can still say what the peer had seen. val die: () -> Nothing = { transcriptFile.writeText(renderTranscript("DIED", null, vault, transcript)) exitProcess(7) } val session = SyncSession( remoteStaticPublicKey = ByteArray(32) { 7 }, send = { bytes -> val message = decodeSyncMessage(bytes) val wireMessage = if (config.mismatch && message is SyncMessage.Manifest) { SyncMessage.Request(emptyList()) } else { message } transcript += "send:${messageType(wireMessage)}" if (config.stopAfter == messageType(wireMessage)) die() val padded = SyncPadding.pad(wireMessage.encodeToBytes()) output.write(Base64.getEncoder().encodeToString(padded)) output.newLine() output.flush() }, receive = { val line = input.readLine() ?: error("cross-client peer closed") val payload = SyncPadding.unpad(Base64.getDecoder().decode(line)) transcript += "recv:${messageType(decodeSyncMessage(payload))}" payload }, close = {}, ) val outcome = runBlocking { if (mode == "initiator") { runSyncInitiator(session, vault, nowMs = System.currentTimeMillis()) } else { runSyncResponder(session, vault, nowMs = System.currentTimeMillis()) } } // A clean stop is only deterministic if our socket is still up when // the peer writes its last frame, so hold it open until the peer // goes away rather than exiting the instant the round ends. if (config.drain) { runCatching { raw.soTimeout = 2_000 while (input.readLine() != null) Unit } } transcriptFile.writeText(renderTranscript(outcome.status.name, outcome, vault, transcript)) assertEquals( "Android process sync outcome", config.expectStatus, outcome.status, ) } } private fun craftedManifest(config: HarnessConfig): SignedDeviceManifest? = config.signedManifestJson?.let { json.decodeFromString(SignedDeviceManifest.serializer(), it) } /** * The production verdict on the peer's manifest. The trusted key stands in * for this device's stored issuer key, and every known issuer resolves to * it, so a revocation signed by another key fails closed. */ private fun manifestVerdict(config: HarnessConfig, manifest: SignedDeviceManifest, nowMs: Long): String { val trustedHex = config.trustedPubkeyHex ?: return "not-checked" val accepted = verifyDeviceManifest( manifest = manifest, issuerSigningPublicKey = hexToPubkey(trustedHex), resolveIssuerSigningKeys = { setOf(trustedHex.lowercase()) }, nowMs = nowMs, ) return if (accepted != null) "accepted" else "rejected" } private fun renderTranscript( status: String, outcome: SyncOutcome?, vault: ProcessVault, transcript: List, ): String = buildString { append("{\"status\":\"").append(status).append("\"") outcome?.error?.let { append(",\"error\":\"").append(it.replace("\"", "\\\"")).append("\"") } append(",\"entriesSent\":").append(outcome?.entriesSent ?: 0) append(",\"entriesReceived\":").append(outcome?.entriesReceived ?: 0) append(",\"applied\":").append(vault.appliedCount) append(",\"staged\":").append(vault.stagedCount) append(",\"manifestAccepted\":").append( vault.manifestVerdict?.let { it == "accepted" } ?: "null", ) append(",\"events\":[") transcript.forEachIndexed { index, event -> if (index > 0) append(',') append('"').append(event).append('"') } append("]}") } private val json = Json { ignoreUnknownKeys = true } private fun configured(name: String): String? = System.getenv(name) ?: System.getProperty(name.lowercase().replace('_', '.')) private fun requireConfigured(name: String): String = configured(name) ?: error("missing $name") private fun messageType(message: SyncMessage): String = when (message) { is SyncMessage.Hello -> "SYNC_HELLO" is SyncMessage.Manifest -> "SYNC_MANIFEST" is SyncMessage.Request -> "SYNC_REQUEST" is SyncMessage.Entries -> "SYNC_ENTRIES" is SyncMessage.Tombstones -> "SYNC_TOMBSTONES" is SyncMessage.DeviceManifest -> "SYNC_DEVICE_MANIFEST" is SyncMessage.Done -> "SYNC_DONE" is SyncMessage.Error -> "SYNC_ERROR" } /** Every knob the matrix can set, in one place. Defaults reproduce a plain round. */ private class HarnessConfig( val id: String, val parity: String, val localVersion: Long, val vaultId: String, val manifestSupport: Boolean, val entryCount: Int, val expectStatus: SyncOutcomeStatus, val drain: Boolean, val mismatch: Boolean, val locked: Boolean, val stopAfter: String?, /** * A manifest the harness signed: the wire shape IS the scenario, and one * signer keeps both clients verifying the same bytes rather than each * signing its own. */ val signedManifestJson: String?, val trustedPubkeyHex: String?, ) { companion object { fun read(id: String, configured: (String) -> String?) = HarnessConfig( id = id, parity = configured("PEACH_SYNC_HARNESS_MTIME_PARITY") ?: "", localVersion = configured("PEACH_SYNC_HARNESS_LOCAL_VERSION")?.toLongOrNull() ?: 7L, vaultId = configured("PEACH_SYNC_HARNESS_VAULT_ID") ?: "cross-client-vault", manifestSupport = configured("PEACH_SYNC_HARNESS_MANIFEST_VERSION") != "3", entryCount = configured("PEACH_SYNC_HARNESS_ENTRY_COUNT")?.toIntOrNull() ?: 1, expectStatus = SyncOutcomeStatus.valueOf( configured("PEACH_SYNC_HARNESS_EXPECT_STATUS") ?: "COMPLETED", ), drain = configured("PEACH_SYNC_HARNESS_DRAIN") == "1", mismatch = configured("PEACH_SYNC_HARNESS_MISMATCH") == "message-order", // A locked session may hold the transport key and stage // ciphertext, but it never authors or applies a device manifest // and never merges entries into the live vault (PB-26-112 C). locked = configured("PEACH_SYNC_HARNESS_LOCKED") == "1", stopAfter = configured("PEACH_SYNC_HARNESS_STOP_AFTER")?.takeIf(String::isNotEmpty), signedManifestJson = configured("PEACH_SYNC_HARNESS_SIGNED_MANIFEST")?.takeIf(String::isNotEmpty), trustedPubkeyHex = configured("PEACH_SYNC_HARNESS_TRUSTED_PUBKEY")?.takeIf(String::isNotEmpty), ) } } private inner class ProcessVault(private val config: HarnessConfig) : SyncVaultAdapter { private val id = config.id // Entries are shared ids so a diff can actually flow. Parity decides // which side is newer for a given index: the same parity on both sides // means equal data versions, opposite parities means entries flow both // ways. Must stay identical to the Browser endpoint's rule. private val ids = (0 until config.entryCount).map { "shared-entry-$it" } private val entries = linkedMapOf().apply { ids.forEachIndexed { index, entryId -> put(entryId, mtimeFor(index)) } } private val blobs = linkedMapOf().apply { ids.forEachIndexed { index, entryId -> put(entryId, "$id-frame-${index % 8}") } } private fun mtimeFor(index: Int): Long { if (config.parity.isEmpty()) return 10L return if (index % 2 == config.parity.toInt()) 15L else 10L } private val tombstones = emptyMap() private val manifest = SignedDeviceManifest( DeviceManifestBody(DEVICE_MANIFEST_VERSION, id, 1_700_000_000_000L, emptyList()), "11".repeat(64), ) /** The production verdict on the peer's manifest, or null if none arrived. */ var manifestVerdict: String? = null private set private val nowMs = System.currentTimeMillis() private val crafted: SignedDeviceManifest? = craftedManifest(config) /** Entries this side merged into its live vault. */ var appliedCount = 0 private set /** Entries this side only staged because the vault is locked. */ var stagedCount = 0 private set private val staged = linkedMapOf() override suspend fun computeVaultId() = config.vaultId override suspend fun getOurDeviceId() = id override suspend fun getLocalVersion() = config.localVersion override suspend fun buildEntryManifest() = entries.toMap() override suspend fun buildTombstoneManifest() = tombstones override suspend fun getEncryptedEntries(entryIds: List) = entryIds.mapNotNull { key -> blobs[key]?.let { key to it } }.toMap() override suspend fun applyEntries(entries: Map, remoteManifest: Map) { if (config.locked) { staged.putAll(entries) stagedCount += entries.size return } appliedCount += entries.size this.entries.putAll(entries.mapValues { remoteManifest[it.key] ?: 0L }) blobs.putAll(entries) } override suspend fun applyTombstones(tombstones: Map) = Unit override suspend fun buildOutgoingDeviceManifest() = if (!config.manifestSupport || config.locked) null else crafted ?: manifest override suspend fun applyIncomingDeviceManifest( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: ByteArray, ) { manifestVerdict = manifestVerdict(config, manifest, nowMs) } override val supportsDeviceManifest = config.manifestSupport && !config.locked } }