944cf61526ab

restructure Android sync around public/private/off relay modes and delete the LAN stack: the ServerSocket listener, the phone-as-relay hub and frames, the code pairing server, mDNS and discovery all go, keeping the relay responder as the only transport (v2.5.0)

time
2026-07-26 15:59 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
LAN retirement
parent
c74f60633f23
31 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/di/VaultModule.kt+0 −6app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+3 −4app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+0 −390app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+11 −27app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+0 −343app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+117 −0app/src/main/java/com/peachpasswords/sync/PairingCode.kt+0 −152app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+0 −310app/src/main/java/com/peachpasswords/sync/PairingSession.kt+23 −0app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+4 −12app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+6 −6app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+1 −1app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+0 −100app/src/main/java/com/peachpasswords/sync/RelayHub.kt+0 −163app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+68 −23app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+8 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+0 −270app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+0 −41app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+11 −59app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+90 −54app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+25 −67app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+36 −15app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+0 −276app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+0 −90app/src/test/java/com/peachpasswords/sync/RelayFramesTest.kt+0 −53app/src/test/java/com/peachpasswords/sync/RelayHubTest.kt+0 −162app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+0 −310
VERSION+1 −1
@@ -1 +1 @@1-2.4.151+2.5.0

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24001520-        versionName "2.4.15"19+        versionCode 24010020+        versionName "2.5.0"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/di/VaultModule.kt+0 −6
@@ -2,8 +2,6 @@22 33 import com.peachpasswords.sync.PairedDeviceRegistry44 import com.peachpasswords.sync.PairedDeviceStore5-import com.peachpasswords.sync.RelayPolicy6-import com.peachpasswords.sync.RelaySettingsStore75 import com.peachpasswords.vault.VaultStorage86 import com.peachpasswords.vault.VaultStore97 import dagger.Binds@@ -22,8 +20,4 @@2220     @Binds2321     @Singleton2422     abstract fun bindPairedDeviceRegistry(impl: PairedDeviceStore): PairedDeviceRegistry25-26-    @Binds27-    @Singleton28-    abstract fun bindRelayPolicy(impl: RelaySettingsStore): RelayPolicy2923 }

view file

app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+3 −4
@@ -58,10 +58,9 @@5858         deviceType = extensionDeviceType(),5959         longTermPublicKeyHex = pubkeyToHex(kp.publicKey),6060         signingPublicKeyHex = pubkeyToHex(signing.publicKeyRaw),61-        // Where peers should dial to reconnect without a pairing code. The62-        // pairing window uses an ephemeral port and then closes, so a peer that63-        // cached that one would never reach us again.64-        wsPort = SyncReconnectListener.SYNC_PORT,61+        // No LAN port is advertised: peers reconnect through the relay62+        // (docs/sync-lan-retirement.md), never by dialling this device.63+        wsPort = null,6564         relayUrl = RelaySettingsStore.readRelayUrl(context),6665     )6766 }

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1
@@ -94,7 +94,7 @@9494         localIdentity: LocalChirpIdentity,9595         vaultFactory: () -> ChirpSyncVaultAdapter?,9696     ) {97-        if (relaySettings.isLanOnly()) {97+        if (!relaySettings.isSyncEnabled()) {9898             watchers.values.forEach(Job::cancel)9999             watchers.clear()100100             return

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+0 −390
@@ -1,390 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.CompletableDeferred4-import kotlinx.coroutines.CoroutineScope5-import kotlinx.coroutines.Dispatchers6-import kotlinx.coroutines.channels.awaitClose7-import kotlinx.coroutines.delay8-import kotlinx.coroutines.flow.Flow9-import kotlinx.coroutines.flow.callbackFlow10-import kotlinx.coroutines.isActive11-import kotlinx.coroutines.launch12-import kotlinx.coroutines.withContext13-import org.json.JSONObject14-import java.net.Inet4Address15-import java.net.NetworkInterface16-import java.net.ServerSocket17-import java.net.Socket18-import javax.inject.Inject19-import javax.inject.Singleton20-21-/**22- * Code-based pairing, phone side. The phone listens and displays a code; the23- * browser dials out and presents the token from it.24- *25- * Wire contract (peach-browser is the authority — `.agent/CONTINUITY.md`26- * 2026-07-24T17:36Z, implemented there in `shared/pairing-dial.ts`):27- *28- *   1. browser dials `ws://<ip>:<port>` taken from the code29- *   2. browser → phone, plaintext BINARY frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}30- *   3. phone verifies the token; on mismatch it closes the socket with NO error31- *      frame — that silence is the invalid-code signal32- *   4. phone → browser, plaintext BINARY: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}33- *      (that key is the phone's Noise NK static `s`)34- *35- * Every frame in this exchange is binary, including the two JSON ones. This36- * document said "text" until 2026-07-24 and the code followed it; the browser's37- * socket is binary end to end, so pairing failed for every correct code. Verify38- * claims like this against `pairing-dial.ts`, not against this comment.39- *   5. Noise NK, phone as RESPONDER, over binary frames40- *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST41- *   7. both derive the same 8-digit SAS from the handshake hash, and BOTH gate42- *      persistence on the user confirming it43- *44- * Step 7 is why [PairingSession] hands the SAS out and waits: the older45- * [MdnsPairingServer] wrote the peer into [PairedDeviceStore] as soon as the46- * handshake completed, before any human had compared anything, which makes the47- * SAS decorative. Nothing is persisted here until [PairingSession.confirm] is48- * called with true.49- */50-sealed interface PairingEvent {51-    /** The listener is up; show [code] to the user. */52-    data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent53-54-    /** A peer completed the handshake. Show [sas]; nothing is stored yet. */55-    data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent56-57-    data class Paired(val deviceName: String) : PairingEvent58-59-    /** The code's window elapsed without a pairing; the user must start again. */60-    data object Expired : PairingEvent61-62-    /** Includes the silent-close case: a peer presented a bad token. */63-    data class Rejected(val reason: String) : PairingEvent64-}65-66-class PairingSession internal constructor() {67-    private val decision = CompletableDeferred<Boolean>()68-    fun confirm(matches: Boolean) { decision.complete(matches) }69-    internal suspend fun await(): Boolean = decision.await()70-}71-72-@Singleton73-class CodePairingServer @Inject constructor(74-    // The interface, not the concrete store: the store needs Android's75-    // EncryptedSharedPreferences, which would make this whole class untestable76-    // off device — and the token/window logic is exactly what wants testing.77-    private val deviceStore: PairedDeviceRegistry,78-) {79-    private var serverSocket: ServerSocket? = null80-81-    companion object {82-        /** Doc says 5–10 minutes; the low end is plenty to walk to a laptop. */83-        const val PAIRING_WINDOW_MS = 5L * 60 * 100084-85-        /** Interfaces whose address a LAN peer cannot route back to. */86-        private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")87-    }88-89-    /**90-     * Bind, publish a code, and accept pairing attempts until the flow is91-     * cancelled. One token per call: after a successful pairing the caller92-     * should stop this flow, which closes the pairing window while leaving any93-     * long-lived sync listener alone.94-     */95-    fun start(96-        scope: CoroutineScope,97-        ourStaticPrivateKey: ByteArray,98-        ourStaticPublicKey: ByteArray,99-        localIdentity: LocalChirpIdentity,100-        windowMs: Long = PAIRING_WINDOW_MS,101-        adoptionSourceDeps: AdoptionSourceDeps? = null,102-    ): Flow<PairingEvent> = callbackFlow {103-        val ip = localIpv4()104-        if (ip == null) {105-            trySend(PairingEvent.Rejected("no_local_network"))106-            close()107-            return@callbackFlow108-        }109-110-        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }111-        serverSocket = socket112-        val token = PairingCode.newToken()113-        val tokenHex = token.joinToString("") { "%02x".format(it) }114-115-        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))116-117-        // The window is not a race against an attacker — there isn't a118-        // realistic one on your own LAN for a single-use code. It just stops an119-        // abandoned code from staying live indefinitely on a phone in a pocket.120-        val expiryJob = scope.launch {121-            delay(windowMs)122-            trySend(PairingEvent.Expired)123-            stop()124-            this@callbackFlow.close()125-        }126-127-        val acceptJob = scope.launch(Dispatchers.IO) {128-            while (isActive && !socket.isClosed) {129-                val client = runCatching { socket.accept() }.getOrNull() ?: break130-                launch {131-                    val event = handleClient(132-                        client,133-                        tokenHex,134-                        ourStaticPrivateKey,135-                        ourStaticPublicKey,136-                        localIdentity,137-                        adoptionSourceDeps,138-                    )139-                    trySend(event)140-                    // One code, one device. A rejected or failed attempt leaves141-                    // the window open so the user can retry without generating142-                    // a new code; only a real pairing closes it.143-                    if (event is PairingEvent.Paired) {144-                        expiryJob.cancel()145-                        stop()146-                        this@callbackFlow.close()147-                    }148-                }149-            }150-        }151-152-        awaitClose {153-            expiryJob.cancel()154-            acceptJob.cancel()155-            stop()156-        }157-    }158-159-    fun stop() {160-        runCatching { serverSocket?.close() }161-        serverSocket = null162-    }163-164-    private suspend fun handleClient(165-        client: Socket,166-        expectedTokenHex: String,167-        ourStaticPrivateKey: ByteArray,168-        ourStaticPublicKey: ByteArray,169-        localIdentity: LocalChirpIdentity,170-        adoptionSourceDeps: AdoptionSourceDeps?,171-    ): PairingEvent = withContext(Dispatchers.IO) {172-        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())173-        var noiseSession: NoiseNKSession? = null174-        try {175-            if (!ws.accept()) {176-                ws.closeSilently()177-                return@withContext PairingEvent.Rejected("not_a_websocket")178-            }179-180-            // The browser sends this as a BINARY frame — `openPairingSocket` in181-            // peach-browser's pairing-dial.ts is a binary seam end to end182-            // (`ws.binaryType = "arraybuffer"`, `send(bytes: Uint8Array)`).183-            // Requiring text here rejected every real dial and closed the socket184-            // without a word, which the browser reports as "invalid code" — so a185-            // perfectly good code always failed. Text is still accepted so any186-            // other caller keeps working.187-            val helloText = when (val frame = ws.receive()) {188-                is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)189-                is WsMessage.Text -> frame.text190-                else -> return@withContext rejectSilently(ws, "no_hello")191-            }192-            val helloJson = runCatching { JSONObject(helloText) }.getOrNull()193-                ?: return@withContext rejectSilently(ws, "bad_hello")194-            if (helloJson.optString("type") != "PEACH_HELLO") {195-                return@withContext rejectSilently(ws, "bad_hello")196-            }197-            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {198-                return@withContext rejectSilently(ws, "bad_token")199-            }200-            val flow = helloJson.optString("flow").ifBlank { null }201-            if (flow != null && flow != "adoption") {202-                return@withContext rejectSilently(ws, "unsupported_flow")203-            }204-205-            // Binary, for the same reason: the browser reads every frame as206-            // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a207-            // string, which converts to a ZERO-LENGTH array rather than208-            // throwing — and a zero-length ACK is also read as an invalid code.209-            ws.send(210-                JSONObject()211-                    .put("type", "PEACH_HELLO_ACK")212-                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })213-                    .toString()214-                    .toByteArray(Charsets.UTF_8),215-            )216-217-            val session = noiseNKResponder(218-                ourStaticPrivateKey,219-                ourStaticPublicKey,220-                { frame -> ws.send(frame) },221-                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },222-            )223-            noiseSession = session224-225-            if (flow == "adoption") {226-                val deps = adoptionSourceDeps227-                    ?: return@withContext rejectSilently(ws, "adoption_unavailable")228-                return@withContext handleAdoption(229-                    ws,230-                    session,231-                    localIdentity,232-                    deps,233-                )234-            }235-236-            // E1: the initiator proves it holds the private half of the key it237-            // will advertise. Bind that proof to this NK transcript before any238-            // identity frame is accepted or SAS consent is requested.239-            val authenticatedInitiatorKey = performResponderPairingAuth(240-                session = session,241-                ourPrivateKey = ourStaticPrivateKey,242-                ourPublicKey = ourStaticPublicKey,243-                sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },244-                receiveEncryptedJson = {245-                    session.decrypt(246-                        (ws.receive() as? WsMessage.Binary)?.bytes247-                            ?: throw IllegalStateException("peer closed"),248-                    )249-                },250-            )251-252-            // Browser first, then us — matching the contract's ordering.253-            val theirIdentity = parsePairIdentityFrame(254-                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),255-            )256-            require(257-                java.security.MessageDigest.isEqual(258-                    authenticatedInitiatorKey,259-                    hexToPubkey(theirIdentity.longTermPublicKeyHex),260-                ),261-            ) { "PAIR_IDENTITY key does not match proven initiator key" }262-            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))263-264-            val sas = SASDerivation.derive(session.handshakeHash)265-            val device = Rendezvous.bindPairState(266-                device = pairIdentityToPairedDevice(theirIdentity),267-                handshakeHash = session.handshakeHash,268-                // The device displaying the code chooses the mesh relay.269-                relayUrl = localIdentity.relayUrl,270-            )271-            val pending = PairingSession()272-            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"273-274-            // Hand the SAS to the UI and block here. Persisting before the user275-            // has compared the digits would make the comparison meaningless.276-            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)277-            confirmationSink?.invoke(confirmation)278-            if (!pending.await()) {279-                ws.closeSilently()280-                return@withContext PairingEvent.Rejected("sas_mismatch")281-            }282-283-            // A direct code pairing means a physically present user confirmed284-            // the SAS on both screens — the only thing allowed to lift a285-            // terminal revocation (see PairedDeviceStore.revoke).286-            deviceStore.clearRevocation(device.deviceId)287-            deviceStore.upsert(device)288-            PairingEvent.Paired(name)289-        } catch (e: Exception) {290-            ws.closeSilently()291-            PairingEvent.Rejected(e.message ?: "pairing_failed")292-        } finally {293-            noiseSession?.close()294-            runCatching { client.close() }295-        }296-    }297-298-    private suspend fun handleAdoption(299-        ws: WebSocketConnection,300-        session: NoiseNKSession,301-        localIdentity: LocalChirpIdentity,302-        sourceDeps: AdoptionSourceDeps,303-    ): PairingEvent {304-        val channel = object : PairingTransport {305-            override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))306-            override suspend fun receive(): ByteArray = session.decrypt(307-                (ws.receive() as? WsMessage.Binary)?.bytes308-                    ?: throw IllegalStateException("peer closed"),309-            )310-            override suspend fun close() = ws.closeSilently()311-        }312-        val request = receiveFrame(channel)313-        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }314-        request as AdoptionFrame.Request315-316-        val pending = PairingSession()317-        val name = request.deviceName.ifBlank { "New device" }318-        confirmationSink?.invoke(319-            PairingEvent.AwaitingConfirmation(320-                SASDerivation.derive(session.handshakeHash),321-                name,322-                pending,323-            ),324-        )325-        if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")326-327-        val pairState = AdoptionPairState(328-            rendezvousSecretHex = Rendezvous.bytesToHex(329-                Rendezvous.deriveSecret(session.handshakeHash),330-            ),331-            relayUrl = localIdentity.relayUrl,332-        )333-        val boundDeps = object : AdoptionSourceDeps {334-            override val sourceIdentity = localIdentity335-            override val pairState = pairState336-            override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()337-            override suspend fun vaultId() = sourceDeps.vaultId()338-            override suspend fun license() = sourceDeps.license()339-            override suspend fun onConsent(request: AdoptionFrame.Request) = true340-            override suspend fun upsertPairedDevice(device: PairedDevice) {341-                sourceDeps.upsertPairedDevice(device)342-            }343-        }344-        return if (grantAdoption(channel, request, boundDeps) != null) {345-            PairingEvent.Paired(name)346-        } else {347-            PairingEvent.Rejected("adoption_rejected")348-        }349-    }350-351-    /**352-     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is353-     * still blocked inside the connection.354-     */355-    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null356-357-    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {358-        ws.closeSilently()359-        return PairingEvent.Rejected(reason)360-    }361-362-    /** The token is low-value, but comparing it in constant time costs nothing. */363-    private fun constantTimeEquals(a: String, b: String): Boolean {364-        return CryptoCompare.utf8(a, b)365-    }366-367-    /**368-     * The address to put in the code — it has to be the one the browser can369-     * actually dial, which is the Wi-Fi address.370-     *371-     * Interface order is not meaningful, so taking the first site-local IPv4372-     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are373-     * site-local and both produce a code that looks fine and then times out374-     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and375-     * tunnels are excluded rather than merely deprioritised.376-     */377-    internal fun localIpv4(): String? = runCatching {378-        val candidates = NetworkInterface.getNetworkInterfaces().toList()379-            .filter { it.isUp && !it.isLoopback && !it.isVirtual }380-            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }381-382-        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()383-            .filterIsInstance<Inet4Address>()384-            .firstOrNull { it.isSiteLocalAddress }385-            ?.hostAddress386-387-        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)388-            ?: candidates.firstNotNullOfOrNull(::addressOf)389-    }.getOrNull()390-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+11 −27
@@ -18,40 +18,24 @@1818     private val vault: VaultRepository,1919     private val deviceStore: PairedDeviceStore,2020     private val pendingVouchedStore: PendingVouchedDeviceStore,21-    private val discoveryClient: PairingDiscoveryClient,2221     private val syncAlertStore: SyncAlertStore,2322 ) {2423     private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())2524     private var inFlight: Job? = null2625 26+    /**27+     * Bring this device back onto the relay so paired peers can reach it.28+     *29+     * It no longer dials anyone. Under the relay-only model this device WAITS30+     * in its rendezvous rooms as the responder and the peer dials in — a31+     * browser extension cannot hold a room open, so it has to be the dialler.32+     * Coming to the foreground therefore means "make sure we are registered",33+     * not "go and sync now". See docs/sync-lan-retirement.md.34+     */2735     fun triggerForegroundSync() {28-        if (inFlight?.isActive == true) return2936         if (!vault.isUnlocked.value) return30-        val peers = foregroundSyncPeers(deviceStore.getAll())31-        if (peers.isEmpty()) return32-        val master = vault.syncMasterKey() ?: return33-        inFlight = scope.launch {34-            try {35-                val identity = buildLocalIdentity(context, master)36-                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)37-                try {38-                    peers.forEach { peer ->39-                        runCatching {40-                            discoveryClient.syncWithKnownDevice(41-                                deviceId = peer.deviceId,42-                                localIdentity = identity,43-                                localStaticPrivateKey = localKeys.secretKey,44-                                vaultFactory = ::buildAdapter,45-                            )46-                        }47-                    }48-                } finally {49-                    VaultCrypto.zero(localKeys.secretKey)50-                }51-            } finally {52-                VaultCrypto.zero(master)53-            }54-        }37+        if (foregroundSyncPeers(deviceStore.getAll()).isEmpty()) return38+        PeachSyncService.start(context)5539     }5640 5741     private fun buildAdapter(): ChirpSyncVaultAdapter? {

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+0 −343
@@ -1,343 +0,0 @@1-package com.peachpasswords.sync2-3-import android.content.Context4-import android.net.nsd.NsdManager5-import android.net.nsd.NsdServiceInfo6-import android.os.Build7-import dagger.hilt.android.qualifiers.ApplicationContext8-import kotlinx.coroutines.CoroutineScope9-import kotlinx.coroutines.Dispatchers10-import kotlinx.coroutines.channels.awaitClose11-import kotlinx.coroutines.flow.Flow12-import kotlinx.coroutines.flow.callbackFlow13-import kotlinx.coroutines.isActive14-import kotlinx.coroutines.launch15-import kotlinx.coroutines.withContext16-import org.json.JSONObject17-import java.net.ServerSocket18-import java.net.Socket19-import java.nio.charset.StandardCharsets20-import javax.inject.Inject21-import javax.inject.Singleton22-23-/**24- * C7 TCP + mDNS reconnect SERVER (responder side).25- *26- * Listens on an OS-assigned TCP port, advertises itself over mDNS as27- * `_peach._tcp`, and for each inbound connection runs the REAL responder28- * pipeline:29- *   1. Resolve PEACH_RECONNECT against paired local state, then run Noise KK30- *      over length-prefixed frames ([SocketFrameIO]).31- *   2. PAIR_IDENTITY frame exchange — encoded/decoded with the SAME private32- *      helpers ChirpPairing uses; since those are file-private there, this draft33- *      re-derives the identity frame via [LocalChirpIdentity] and a minimal34- *      JSON, and parses the peer's via [parsePairIdentityFrame]. See35- *      C7-INTEGRATION.md: the cleanest integration promotes ChirpPairing's36- *      encode/decodePairingFrame to internal and deletes these shims.37- *   3. Encrypted vault sync via the REAL [runChirpSyncResponder] over a38- *      [ChirpSyncSession] backed by the Noise session.39- *40- * Reconnect is strictly KK. The opaque selector capability is resolved against41- * local paired state before the handshake, both static keys are authenticated42- * by the named pattern, and failure never falls back to first-pair NK.43- *  - The brief calls `syncAdapter.buildResponse/applyRequest`. NO SUCH METHODS.44- *    The real per-round protocol is [runChirpSyncResponder]; the adapter is a45- *    [ChirpSyncVaultAdapter] ([VaultSyncAdapter]) with getEncryptedEntries/46- *    applyEntries/etc. We drive the whole round, not single request/response.47- */48-@Singleton49-class MdnsPairingServer @Inject constructor(50-    private val deviceStore: PairedDeviceStore,51-    @ApplicationContext private val ctx: Context,52-) {53-    private var serverSocket: ServerSocket? = null54-    private var nsdManager: NsdManager? = null55-    private var registrationListener: NsdManager.RegistrationListener? = null56-57-    /**58-     * Start listening + advertise + accept. Caller supplies:59-     *  - [ourStaticPrivateKey]/[ourStaticPublicKey]: the device's long-term sync60-     *    identity (from [deriveSyncIdentityKeypair]) — the KK responder static61-     *    key, also published in mDNS TXT "pk".62-     *  - [vaultFactory]: builds a fresh [ChirpSyncVaultAdapter] per connection63-     *    (the vault may lock/unlock between connections).64-     *  - [localIdentity]: our PAIR_IDENTITY payload.65-     */66-    fun start(67-        scope: CoroutineScope,68-        ourStaticPrivateKey: ByteArray,69-        ourStaticPublicKey: ByteArray,70-        localIdentity: LocalChirpIdentity,71-        vaultFactory: () -> ChirpSyncVaultAdapter?,72-    ): Flow<PairingServerEvent> = callbackFlow {73-        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }74-        serverSocket = socket75-        val port = socket.localPort76-77-        registerMdns(port, ourStaticPublicKey, localIdentity.deviceId)78-        trySend(PairingServerEvent.Started(port))79-80-        val acceptJob = scope.launch(Dispatchers.IO) {81-            while (isActive && !socket.isClosed) {82-                val client = try {83-                    socket.accept()84-                } catch (_: Throwable) {85-                    break86-                }87-                launch {88-                    val event = handleClient(client, ourStaticPrivateKey, ourStaticPublicKey, localIdentity, vaultFactory)89-                    trySend(event)90-                }91-            }92-        }93-94-        awaitClose {95-            acceptJob.cancel()96-            stop()97-        }98-    }99-100-    fun stop() {101-        registrationListener?.let { l -> runCatching { nsdManager?.unregisterService(l) } }102-        registrationListener = null103-        runCatching { serverSocket?.close() }104-        serverSocket = null105-    }106-107-    private suspend fun handleClient(108-        socket: Socket,109-        ourStaticPrivateKey: ByteArray,110-        ourStaticPublicKey: ByteArray,111-        localIdentity: LocalChirpIdentity,112-        vaultFactory: () -> ChirpSyncVaultAdapter?,113-    ): PairingServerEvent {114-        val transport = SocketFrameIO(115-            input = socket.getInputStream(),116-            output = socket.getOutputStream(),117-            onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },118-        )119-        var noiseSession: NoiseNKSession? = null120-        return try {121-            val selector = JSONObject(String(transport.receive(), Charsets.UTF_8))122-            require(selector.optString("type") == "PEACH_RECONNECT") { "expected PEACH_RECONNECT" }123-            val rendezvousId = selector.optString("rendezvous_id")124-            val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)125-                ?: throw IllegalArgumentException("unknown reconnect peer")126-            val initiatorKeyHex = known.longTermPublicKeyHex127-                ?: throw IllegalArgumentException("known peer missing static key")128-            val initiatorKey = hexToPubkey(initiatorKeyHex)129-130-            val session = noiseKKResponder(131-                ourStaticPrivateKey,132-                ourStaticPublicKey,133-                initiatorKey,134-                transport::send,135-                transport::receive,136-            )137-            noiseSession = session138-139-            // 2. PAIR_IDENTITY exchange (responder reads first, then answers) —140-            //    mirrors ChirpPairing.runPairingResponder ordering.141-            val theirIdentity = parsePairIdentityFrame(142-                SyncPadding.unpad(session.decrypt(transport.receive())),143-            )144-            require(theirIdentity.deviceId == known.deviceId) { "peer device id changed after KK" }145-            require(146-                CryptoCompare.hex(theirIdentity.longTermPublicKeyHex, initiatorKeyHex),147-            ) { "peer identity does not match authenticated KK key" }148-            transport.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))149-150-            val sas = SASDerivation.derive(session.handshakeHash)151-            // 3. Encrypted vault sync — real responder round over a ChirpSyncSession.152-            val vault = vaultFactory()153-            if (vault != null) {154-                vault.bindAuthenticatedPeer(known)155-                val syncSession = chirpSessionOver(session, transport, initiatorKey)156-                val outcome = runChirpSyncResponder(syncSession, vault)157-                if (outcome.status == SyncOutcomeStatus.COMPLETED) {158-                    deviceStore.updateLastSync(known.deviceId)159-                }160-            }161-            transport.close()162-            PairingServerEvent.SyncComplete(known.deviceId, sas)163-        } catch (e: Throwable) {164-            transport.close()165-            PairingServerEvent.Error(e.message ?: "pairing_server_error")166-        } finally {167-            noiseSession?.close()168-        }169-    }170-171-    private fun registerMdns(port: Int, staticPub: ByteArray, deviceId: String) {172-        val manager = ctx.getSystemService(NsdManager::class.java) ?: return173-        nsdManager = manager174-        val info = NsdServiceInfo().apply {175-            serviceName = buildServiceName(deviceId)176-            serviceType = PEACH_SERVICE_TYPE177-            this.port = port178-            // NsdServiceInfo.setAttribute is API 21+; minSdk is 26.179-            setAttribute("v", PROTOCOL_VERSION)180-            setAttribute("pk", pubkeyToHex(staticPub))181-            setAttribute("id", deviceId)182-        }183-        val listener = object : NsdManager.RegistrationListener {184-            override fun onServiceRegistered(info: NsdServiceInfo) {}185-            override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {}186-            override fun onServiceUnregistered(info: NsdServiceInfo) {}187-            override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {}188-        }189-        registrationListener = listener190-        manager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)191-    }192-193-    private fun buildServiceName(deviceId: String): String {194-        val shortId = deviceId.take(8)195-        val model = Build.MODEL.replace(Regex("[^A-Za-z0-9-]"), "")196-        // mDNS service-instance names are capped at 63 bytes; reserve room for197-        // the "Peach-" prefix and the "-${shortId}" suffix.198-        val maxModel = 63 - "Peach-".length - 1 - shortId.length199-        val trimmedModel = if (model.length > maxModel && maxModel > 0) model.take(maxModel) else model200-        return "Peach-$trimmedModel-$shortId".take(63)201-    }202-203-    private companion object {204-        const val PEACH_SERVICE_TYPE = "_peach._tcp"205-        const val PROTOCOL_VERSION = "1"206-    }207-}208-209-sealed class PairingServerEvent {210-    data class Started(val port: Int) : PairingServerEvent()211-    data class SyncComplete(val deviceId: String, val sas: String) : PairingServerEvent()212-    data class Error(val message: String) : PairingServerEvent()213-    data object Stopped : PairingServerEvent()214-}215-216-/* ── PAIR_IDENTITY shims (see C7-INTEGRATION.md item: promote ChirpPairing's217-   private encode/decodePairingFrame to internal and drop these) ───────────── */218-219-internal data class PairIdentity(220-    val deviceId: String,221-    val deviceName: String,222-    val deviceType: DeviceType,223-    val longTermPublicKeyHex: String,224-    val signingPublicKeyHex: String?,225-    val wsPort: Int?,226-    val relayUrl: String?,227-)228-229-internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {230-    val sb = StringBuilder()231-    sb.append('{')232-    sb.append("\"type\":\"PAIR_IDENTITY\"")233-    sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')234-    sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')235-    sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')236-    sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')237-    sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')238-    id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }239-    sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"')240-    sb.append('}')241-    return sb.toString().toByteArray(StandardCharsets.UTF_8)242-}243-244-internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {245-    val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))246-    require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }247-    val deviceType = when (map["device_type"]) {248-        "extension" -> DeviceType.EXTENSION249-        "android" -> DeviceType.ANDROID250-        "ios" -> DeviceType.IOS251-        else -> error("device_type missing")252-    }253-    val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")254-    require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }255-    return PairIdentity(256-        deviceId = map["device_id"] ?: error("device_id missing"),257-        deviceName = map["device_name"] ?: error("device_name missing"),258-        deviceType = deviceType,259-        longTermPublicKeyHex = ltpk,260-        signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },261-        wsPort = map["ws_port"]?.toIntOrNull(),262-        relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl),263-    )264-}265-266-internal fun pairIdentityToPairedDevice(id: PairIdentity): PairedDevice = PairedDevice(267-    deviceId = id.deviceId,268-    deviceName = id.deviceName,269-    deviceType = id.deviceType.wireValue,270-    longTermPublicKeyHex = id.longTermPublicKeyHex,271-    signingPublicKeyHex = id.signingPublicKeyHex,272-    trustSource = "direct",273-    wsPort = id.wsPort,274-    relayUrl = id.relayUrl,275-    pairedAt = System.currentTimeMillis(),276-    lastSyncedAt = System.currentTimeMillis(),277-)278-279-/**280- * Build a [ChirpSyncSession] over an established Noise session + frame281- * transport, so the REAL [runChirpSyncInitiator]/[runChirpSyncResponder] can run282- * the encrypted vault exchange. Send/receive splice the Noise cipher onto the283- * length-prefixed frame layer; `remoteStaticPublicKey` is informational here.284- */285-internal fun chirpSessionOver(286-    session: NoiseNKSession,287-    transport: SocketFrameIO,288-    remoteStaticPublicKey: ByteArray = ByteArray(0),289-): ChirpSyncSession =290-    ChirpSyncSession(291-        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),292-        // ChirpSyncSession.send is non-suspending;293-        // SocketFrameIO.sendBlocking writes a frame synchronously.294-        send = { plaintext -> transport.sendBlocking(session.encrypt(SyncPadding.pad(plaintext))) },295-        receive = { SyncPadding.unpad(session.decrypt(transport.receive())) },296-        close = {297-            try {298-                session.close()299-            } finally {300-                transport.close()301-            }302-        },303-    )304-305-private fun jsonEscape(value: String): String = buildString(value.length) {306-    for (ch in value) when (ch) {307-        '\\' -> append("\\\\"); '"' -> append("\\\"")308-        '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")309-        else -> append(ch)310-    }311-}312-313-private fun miniJsonObject(text: String): Map<String, String> {314-    val trimmed = text.trim()315-    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }316-    val body = trimmed.substring(1, trimmed.length - 1)317-    val out = linkedMapOf<String, String>()318-    var i = 0319-    while (i < body.length) {320-        while (i < body.length && body[i].isWhitespace()) i++321-        if (i >= body.length || body[i] != '"') break322-        i++323-        val keyStart = i324-        while (i < body.length && body[i] != '"') i++325-        val key = body.substring(keyStart, i); i++326-        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++327-        val value: String328-        if (i < body.length && body[i] == '"') {329-            i++330-            val vStart = i331-            while (i < body.length && body[i] != '"') i++332-            value = body.substring(vStart, i); i++333-        } else {334-            val vStart = i335-            while (i < body.length && body[i] != ',') i++336-            value = body.substring(vStart, i).trim()337-        }338-        out[key] = value339-        while (i < body.length && body[i].isWhitespace()) i++340-        if (i < body.length && body[i] == ',') i++341-    }342-    return out343-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+117 −0
@@ -0,0 +1,117 @@1+package com.peachpasswords.sync2+3+import java.nio.charset.StandardCharsets4+import java.security.MessageDigest5+6+/**7+ * The PAIR_IDENTITY frame: who a peer claims to be, exchanged inside the8+ * encrypted session once and pinned thereafter.9+ *10+ * Lived in the retired LAN listeners (`MdnsPairingServer` /11+ * `SyncReconnectListener`) until relay-only sync12+ * (docs/sync-lan-retirement.md). Nothing here is LAN-specific — the relay13+ * reconnect responder speaks the same frame — so it moved rather than went.14+ */15+data class PairIdentity(16+    val deviceId: String,17+    val deviceName: String,18+    val deviceType: DeviceType,19+    val longTermPublicKeyHex: String,20+    val signingPublicKeyHex: String?,21+    val wsPort: Int?,22+    val relayUrl: String?,23+)24+25+internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {26+    val sb = StringBuilder()27+    sb.append('{')28+    sb.append("\"type\":\"PAIR_IDENTITY\"")29+    sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')30+    sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')31+    sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')32+    sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')33+    sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')34+    id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }35+    sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"')36+    sb.append('}')37+    return sb.toString().toByteArray(StandardCharsets.UTF_8)38+}39+40+internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {41+    val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))42+    require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }43+    val deviceType = when (map["device_type"]) {44+        "extension" -> DeviceType.EXTENSION45+        "android" -> DeviceType.ANDROID46+        "ios" -> DeviceType.IOS47+        else -> error("device_type missing")48+    }49+    val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")50+    require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }51+    return PairIdentity(52+        deviceId = map["device_id"] ?: error("device_id missing"),53+        deviceName = map["device_name"] ?: error("device_name missing"),54+        deviceType = deviceType,55+        longTermPublicKeyHex = ltpk,56+        signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },57+        wsPort = map["ws_port"]?.toIntOrNull(),58+        relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl),59+    )60+}61+62+63+private fun jsonEscape(value: String): String = buildString(value.length) {64+    for (ch in value) when (ch) {65+        '\\' -> append("\\\\"); '"' -> append("\\\"")66+        '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")67+        else -> append(ch)68+    }69+}70+71+private fun miniJsonObject(text: String): Map<String, String> {72+    val trimmed = text.trim()73+    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }74+    val body = trimmed.substring(1, trimmed.length - 1)75+    val out = linkedMapOf<String, String>()76+    var i = 077+    while (i < body.length) {78+        while (i < body.length && body[i].isWhitespace()) i++79+        if (i >= body.length || body[i] != '"') break80+        i++81+        val keyStart = i82+        while (i < body.length && body[i] != '"') i++83+        val key = body.substring(keyStart, i); i++84+        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++85+        val value: String86+        if (i < body.length && body[i] == '"') {87+            i++88+            val vStart = i89+            while (i < body.length && body[i] != '"') i++90+            value = body.substring(vStart, i); i++91+        } else {92+            val vStart = i93+            while (i < body.length && body[i] != ',') i++94+            value = body.substring(vStart, i).trim()95+        }96+        out[key] = value97+        while (i < body.length && body[i].isWhitespace()) i++98+        if (i < body.length && body[i] == ',') i++99+    }100+    return out101+}102+103+/**104+ * A presented identity is only trusted when it matches the paired record AND105+ * the key Noise actually authenticated. The frame is self-asserted; the106+ * authenticated static key is not, so the comparison is anchored there.107+ */108+internal fun identityMatchesAuthenticatedPeer(109+    known: PairedDevice,110+    presented: PairIdentity,111+    authenticatedKey: ByteArray,112+): Boolean {113+    val expected = known.longTermPublicKeyHex ?: return false114+    return known.deviceId == presented.deviceId &&115+        CryptoCompare.hex(expected, presented.longTermPublicKeyHex) &&116+        MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))117+}

view file

app/src/main/java/com/peachpasswords/sync/PairingCode.kt+0 −152
@@ -1,152 +0,0 @@1-package com.peachpasswords.sync2-3-import java.security.SecureRandom4-5-/**6- * The compact join code this phone displays and the user types into the7- * browser. Exact port of the browser's `shared/pairing-code.ts`, which is the8- * authoritative definition of the wire format — the browser only ever decodes,9- * the phone is the only side that generates.10- *11- * Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),12- * in Crockford's Base32, displayed in groups of four:13- *14- *     A74H-48J3-KI32-OEP415- *16- * Token length is deliberately open — everything after the port is token — so17- * this side can pick anywhere in the 4–10 byte range with no coordinated change18- * on the browser.19- *20- * SECURITY: the token travels in the clear in the hello frame and is NOT the21- * security boundary; the user-compared SAS after the Noise NK handshake is. The22- * token only stops an unrelated host on the same LAN from consuming the pairing23- * window.24- */25-object PairingCode {26-    private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"27-    private const val IP_BYTES = 428-    private const val PORT_BYTES = 229-    // Decoding stays lenient about token length so a peer on an older build30-    // (8-byte tokens, 23 characters) still pairs instead of hard-failing.31-    // Encoding is strict — see TOKEN_BYTES.32-    private const val MIN_TOKEN_BYTES = 433-    private const val MAX_TOKEN_BYTES = 1634-35-    /**36-     * 4 bytes, which is what a four-group code actually costs.37-     *38-     * Base32 packs 5 bits per character, so the code length is fixed by the39-     * payload: IP(4) + port(2) + token(n) bytes → ceil((6+n)*8/5) characters.40-     * A 4-byte token is 10 bytes → exactly 16 characters → four clean groups,41-     * and is the only size that lands on a group boundary. 8 bytes was 2342-     * characters and six groups, which is the code June was looking at.43-     *44-     * The brief asks for both "8–10 byte token" and "~4 groups"; those are not45-     * the same code. Its own worked example — A74H-48J3-KI32-OEP4, 1646-     * characters — is a 4-byte token, so the example is what this follows.47-     *48-     * 32 bits is sound here because the token is not the security boundary:49-     * it is single-use, lives for one short pairing window, is only reachable50-     * from the same LAN, and a peer that guesses it still cannot pair without51-     * the user confirming a matching SAS. Its whole job is to stop an52-     * unrelated host from consuming the window. The browser independently sets53-     * its floor at 4 bytes ([MIN_TOKEN_BYTES] there), so this is the shortest54-     * code both sides accept rather than a number picked to be short.55-     */56-    const val TOKEN_BYTES = 457-58-    private val rng = SecureRandom()59-60-    data class Parsed(val ip: String, val port: Int, val tokenHex: String)61-62-    fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes)63-64-    fun encode(ip: String, port: Int, token: ByteArray): String {65-        val octets = ip.split(".")66-        require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }67-        require(port in 1..65535) { "invalid port: $port" }68-        // Strict, mirroring the browser: a longer token silently produces a69-        // longer code, which is exactly the bug this guard exists to prevent.70-        require(token.size == TOKEN_BYTES) {71-            "token must be exactly $TOKEN_BYTES bytes (got ${token.size}); " +72-                "every extra byte adds 1.6 characters to the code"73-        }74-75-        val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)76-        for (i in 0 until IP_BYTES) {77-            val n = octets[i].toIntOrNull()78-            require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" }79-            bytes[i] = n.toByte()80-        }81-        bytes[4] = ((port shr 8) and 0xff).toByte()82-        bytes[5] = (port and 0xff).toByte()83-        token.copyInto(bytes, IP_BYTES + PORT_BYTES)84-        return group(encodeCrockford(bytes))85-    }86-87-    /** Null for anything that is not a well-formed code, never an exception. */88-    fun parse(input: String): Parsed? {89-        val bytes = decodeCrockford(input) ?: return null90-        val tokenBytes = bytes.size - IP_BYTES - PORT_BYTES91-        if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null92-        val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)93-        if (port == 0) return null94-        return Parsed(95-            ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() },96-            port = port,97-            tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(),98-        )99-    }100-101-    fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }102-103-    private fun group(chars: String): String =104-        chars.chunked(4).joinToString("-")105-106-    private fun encodeCrockford(bytes: ByteArray): String {107-        val s = StringBuilder()108-        var acc = 0109-        var bits = 0110-        for (b in bytes) {111-            acc = (acc shl 8) or (b.toInt() and 0xff)112-            bits += 8113-            while (bits >= 5) {114-                bits -= 5115-                s.append(ALPHABET[(acc shr bits) and 0x1f])116-            }117-        }118-        if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f])119-        return s.toString()120-    }121-122-    private fun decodeCrockford(input: String): ByteArray? {123-        // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and124-        // O -> 0 so a code misread off a phone screen still resolves.125-        val clean = buildString {126-            for (c in input.uppercase()) when (c) {127-                ' ', '-', '\t', '\n' -> Unit128-                'I', 'L' -> append('1')129-                'O' -> append('0')130-                else -> append(c)131-            }132-        }133-        if (clean.isEmpty()) return null134-135-        // Trailing bits that don't complete a byte are padding and are dropped.136-        val out = ByteArray(clean.length * 5 / 8)137-        var acc = 0138-        var bits = 0139-        var o = 0140-        for (ch in clean) {141-            val v = ALPHABET.indexOf(ch)142-            if (v < 0) return null143-            acc = (acc shl 5) or v144-            bits += 5145-            if (bits >= 8) {146-                bits -= 8147-                out[o++] = ((acc shr bits) and 0xff).toByte()148-            }149-        }150-        return out151-    }152-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+0 −310
@@ -1,310 +0,0 @@1-package com.peachpasswords.sync2-3-import android.content.Context4-import android.net.nsd.NsdManager5-import android.net.nsd.NsdServiceInfo6-import android.net.wifi.WifiManager7-import dagger.hilt.android.qualifiers.ApplicationContext8-import kotlinx.coroutines.Dispatchers9-import kotlinx.coroutines.channels.awaitClose10-import kotlinx.coroutines.flow.Flow11-import kotlinx.coroutines.flow.callbackFlow12-import kotlinx.coroutines.flow.first13-import kotlinx.coroutines.withContext14-import kotlinx.coroutines.withTimeoutOrNull15-import org.json.JSONObject16-import java.net.InetSocketAddress17-import java.net.Socket18-import java.net.SocketTimeoutException19-import javax.inject.Inject20-import javax.inject.Singleton21-22-/**23- * C7 TCP + mDNS reconnect CLIENT (initiator side). Discovers `_peach._tcp`24- * peers, dials one, and runs the REAL initiator pipeline:25- *   1. An opaque, rotating PEACH_RECONNECT capability lets the responder choose26- *      an already-paired local record, then Noise KK authenticates both stored27- *      static keys.28- *   2. PAIR_IDENTITY exchange (initiator writes first, then reads), matching29- *      ChirpPairing.runPairingInitiator ordering.30- *   3. SAS via the REAL [SASDerivation.derive] over the Noise handshake hash.31- *   4. Encrypted vault sync via the REAL [runChirpSyncInitiator] over a32- *      [ChirpSyncSession].33- *34- * ── Reconciliation with the brief ──────────────────────────────────────────35- *  - Reconnect is strictly KK: both local static keys are inputs and a failure36- *    never negotiates or retries NK. The returned [NoiseNKSession] interface is37- *    transport-neutral legacy naming, not the active handshake pattern.38- *  - `syncAdapter.buildRequest/applyResponse` do NOT exist. Real round driver is39- *    [runChirpSyncInitiator] against a [ChirpSyncVaultAdapter].40- *  - `peer.serviceInfo.host/.port`: on API < 34, [NsdServiceInfo.getHost] is41- *    deprecated; on the supported floor it still returns the resolved42- *    InetAddress, which we use for the dial. The resolved port is authoritative.43- */44-@Singleton45-class PairingDiscoveryClient @Inject constructor(46-    @ApplicationContext private val ctx: Context,47-    private val deviceStore: PairedDeviceStore,48-) {49-    /**50-     * Discover nearby Peach instances via mDNS. Each found service is resolved,51-     * its TXT records parsed into a [DiscoveredPeer], and emitted. The flow runs52-     * until the collector cancels (e.g. via kotlinx `.timeout(...)` or `first`).53-     */54-    fun discoverPeers(): Flow<DiscoveredPeer> = callbackFlow {55-        val manager = ctx.getSystemService(NsdManager::class.java)56-            ?: run { close(); return@callbackFlow }57-58-        // mDNS/NSD relies on Wi-Fi multicast, which Android drops by default to save59-        // power. Hold a MulticastLock for the lifetime of discovery so service60-        // announcements actually reach us (requires CHANGE_WIFI_MULTICAST_STATE).61-        val multicastLock = (ctx.getSystemService(Context.WIFI_SERVICE) as? WifiManager)62-            ?.createMulticastLock("peach-mdns")63-            ?.apply { setReferenceCounted(false); runCatching { acquire() } }64-65-        val resolveListener = object : NsdManager.ResolveListener {66-            override fun onServiceResolved(resolved: NsdServiceInfo) {67-                parsePeer(resolved)?.let { trySend(it) }68-            }69-            override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) {}70-        }71-72-        val discoveryListener = object : NsdManager.DiscoveryListener {73-            override fun onServiceFound(info: NsdServiceInfo) {74-                if (info.serviceType.trimEnd('.') == PEACH_SERVICE_TYPE) {75-                    runCatching { manager.resolveService(info, resolveListener) }76-                }77-            }78-            override fun onServiceLost(info: NsdServiceInfo) {}79-            override fun onDiscoveryStarted(serviceType: String) {}80-            override fun onDiscoveryStopped(serviceType: String) {}81-            override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { close() }82-            override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}83-        }84-85-        manager.discoverServices(PEACH_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener)86-        awaitClose {87-            runCatching { manager.stopServiceDiscovery(discoveryListener) }88-            multicastLock?.let { if (it.isHeld) runCatching { it.release() } }89-        }90-    }91-92-    /**93-     * Dial [peer], run the Noise KK initiator handshake, exchange identities and94-     * derive the SAS, then run one initiator sync round.95-     *96-     * Caller supplies the local static identity and private key required by KK,97-     * plus a vault adapter factory. The pre-handshake selector is an opaque98-     * rendezvous capability; it never discloses either stored public key.99-     */100-    private suspend fun connectAndSync(101-        peer: DiscoveredPeer,102-        localIdentity: LocalChirpIdentity,103-        localStaticPrivateKey: ByteArray,104-        rendezvousId: String,105-        vaultFactory: () -> ChirpSyncVaultAdapter?,106-        connectTimeoutMs: Int = 15_000,107-    ): PairingResult = withContext(Dispatchers.IO) {108-        val socket = Socket()109-        var noiseSession: NoiseNKSession? = null110-        try {111-            socket.connect(InetSocketAddress(peer.host, peer.port), connectTimeoutMs)112-            socket.soTimeout = connectTimeoutMs113-114-            val transport = SocketFrameIO(115-                input = socket.getInputStream(),116-                output = socket.getOutputStream(),117-                onClose = { withContext(Dispatchers.IO) { runCatching { socket.close() } } },118-            )119-120-            val localPublicKey = hexToPubkey(localIdentity.longTermPublicKeyHex)121-            transport.send(122-                JSONObject()123-                    .put("type", "PEACH_RECONNECT")124-                    .put("rendezvous_id", rendezvousId)125-                    .toString()126-                    .toByteArray(Charsets.UTF_8),127-            )128-            val session = try {129-                noiseKKInitiator(130-                    localStaticPrivateKey,131-                    localPublicKey,132-                    peer.remoteStaticPublicKey,133-                    transport::send,134-                    transport::receive,135-                )136-            } catch (e: Throwable) {137-                transport.close()138-                return@withContext PairingResult.Error(PairingError.HANDSHAKE_FAILED)139-            }140-            noiseSession = session141-142-            // PAIR_IDENTITY: initiator writes first, then reads (mirrors143-            // ChirpPairing.runPairingInitiator).144-            transport.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))145-            val theirIdentity = parsePairIdentityFrame(146-                SyncPadding.unpad(session.decrypt(transport.receive())),147-            )148-            require(theirIdentity.deviceId == peer.deviceId) { "peer device id changed after KK" }149-            require(150-                CryptoCompare.hex(151-                    theirIdentity.longTermPublicKeyHex,152-                    pubkeyToHex(peer.remoteStaticPublicKey),153-                ),154-            ) { "peer identity does not match authenticated KK key" }155-156-            val sas = SASDerivation.derive(session.handshakeHash)157-            val incoming = pairIdentityToPairedDevice(theirIdentity)158-            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())159-                ?: deviceStore.get(incoming.deviceId)160-            val device = incoming.copy(161-                rendezvousSecretHex = existing?.rendezvousSecretHex,162-                relayUrl = existing?.relayUrl ?: incoming.relayUrl,163-            )164-            deviceStore.upsert(device)165-166-            // Encrypted vault sync — real initiator round.167-            val vault = vaultFactory()168-            if (vault != null) {169-                vault.bindAuthenticatedPeer(device)170-                val syncSession = chirpSessionOver(session, transport, peer.remoteStaticPublicKey)171-                val outcome = runChirpSyncInitiator(syncSession, vault)172-                if (outcome.status == SyncOutcomeStatus.COMPLETED) {173-                    deviceStore.updateLastSync(device.deviceId)174-                }175-            }176-177-            transport.close()178-            PairingResult.Success(device, sas)179-        } catch (e: SocketTimeoutException) {180-            runCatching { socket.close() }181-            PairingResult.Error(PairingError.TIMEOUT)182-        } catch (e: Throwable) {183-            runCatching { socket.close() }184-            PairingResult.Error(PairingError.IO_ERROR)185-        } finally {186-            noiseSession?.close()187-        }188-    }189-190-    /**191-     * Dial a known peer: the cached address first, discovery only as a fallback.192-     *193-     * [cached] is the peer's last-known endpoint, refreshed by the listener on194-     * every inbound reconnect. Trying it first is the "silent reconnect" the195-     * sync design asks for (§6) — this used to go straight to mDNS and report196-     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at197-     * its cached address failed to sync whenever discovery was unavailable,198-     * which on a home network is often.199-     *200-     * The cached attempt gets a short timeout: a stale address is the normal201-     * case after a network change, and it must not delay the fallback.202-     */203-    suspend fun syncWithKnownDevice(204-        deviceId: String,205-        localIdentity: LocalChirpIdentity,206-        localStaticPrivateKey: ByteArray,207-        vaultFactory: () -> ChirpSyncVaultAdapter?,208-        discoveryTimeoutMs: Long = 5_000L,209-        cached: DiscoveredPeer? = null,210-        cachedTimeoutMs: Int = 2_000,211-    ): PairingResult {212-        val paired = deviceStore.get(deviceId)213-            ?: return PairingResult.Error(PairingError.NOT_FOUND)214-        val secret = paired.rendezvousSecretHex215-            ?.let { runCatching { Rendezvous.secretFromHex(it) }.getOrNull() }216-            ?: return PairingResult.Error(PairingError.HANDSHAKE_FAILED)217-        val rendezvousIds = Rendezvous.dialingIds(218-            secret,219-            System.currentTimeMillis() / 1000L,220-        )221-        if (cached != null) {222-            val pinnedCached = pinDiscoveredPeerToPairedState(paired, cached)223-            for (rendezvousId in rendezvousIds) {224-                val viaCache = connectAndSync(225-                    pinnedCached,226-                    localIdentity,227-                    localStaticPrivateKey,228-                    rendezvousId,229-                    vaultFactory,230-                    connectTimeoutMs = cachedTimeoutMs,231-                )232-                if (viaCache is PairingResult.Success) return viaCache233-            }234-        }235-        val discovered = withTimeoutOrNull(discoveryTimeoutMs) {236-            discoverPeers().first { it.deviceId == deviceId }237-        } ?: return PairingResult.Error(PairingError.NOT_FOUND)238-        val peer = pinDiscoveredPeerToPairedState(paired, discovered)239-        var last: PairingResult = PairingResult.Error(PairingError.HANDSHAKE_FAILED)240-        for (rendezvousId in rendezvousIds) {241-            last = connectAndSync(242-                peer,243-                localIdentity,244-                localStaticPrivateKey,245-                rendezvousId,246-                vaultFactory,247-            )248-            if (last is PairingResult.Success) return last249-        }250-        return last251-    }252-253-    private fun parsePeer(info: NsdServiceInfo): DiscoveredPeer? {254-        val attrs = info.attributes ?: return null255-        val pkHex = attrs["pk"]?.let { String(it, Charsets.UTF_8) } ?: return null256-        if (!pkHex.matches(Regex("^[0-9a-f]{64}$"))) return null257-        val deviceId = attrs["id"]?.let { String(it, Charsets.UTF_8) } ?: return null258-        @Suppress("DEPRECATION")259-        val host = info.host?.hostAddress ?: return null260-        return DiscoveredPeer(261-            deviceId = deviceId,262-            deviceName = info.serviceName,263-            host = host,264-            port = info.port,265-            remoteStaticPublicKey = hexToPubkey(pkHex),266-        )267-    }268-269-    private companion object {270-        const val PEACH_SERVICE_TYPE = "_peach._tcp"271-    }272-}273-274-data class DiscoveredPeer(275-    val deviceId: String,276-    val deviceName: String,277-    val host: String,278-    val port: Int,279-    /** Paired responder static public key, parsed from TXT "pk" and pinned in KK. */280-    val remoteStaticPublicKey: ByteArray,281-)282-283-/**284- * Discovery supplies reachability only. KK's responder key always comes from285- * paired local state; a spoofed mDNS TXT record can redirect or deny service,286- * but cannot choose the key the initiator authenticates.287- */288-internal fun pinDiscoveredPeerToPairedState(289-    paired: PairedDevice,290-    discovered: DiscoveredPeer,291-): DiscoveredPeer {292-    require(discovered.deviceId == paired.deviceId) { "discovered peer id does not match paired state" }293-    val expectedKey = paired.longTermPublicKeyHex294-        ?: throw IllegalArgumentException("paired peer missing static key")295-    return discovered.copy(remoteStaticPublicKey = hexToPubkey(expectedKey))296-}297-298-sealed class PairingResult {299-    data class Success(val device: PairedDevice, val sasCode: String) : PairingResult()300-    data class Error(val reason: PairingError) : PairingResult()301-}302-303-enum class PairingError {304-    NOT_FOUND,305-    HANDSHAKE_FAILED,306-    VERSION_MISMATCH,307-    ALREADY_PAIRED,308-    TIMEOUT,309-    IO_ERROR,310-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/PairingSession.kt+23 −0
@@ -0,0 +1,23 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CompletableDeferred4+5+/**6+ * The user's SAS decision, handed to the UI and awaited by the live pairing7+ * session.8+ *9+ * The handler blocks inside the connection while the two screens are compared,10+ * so the decision has to reach it out of band rather than as a return value.11+ *12+ * Lived in the retired LAN `CodePairingServer` until relay-only pairing13+ * (docs/sync-lan-retirement.md); it was never LAN-specific.14+ */15+class PairingSession internal constructor() {16+    private val decision = CompletableDeferred<Boolean>()17+18+    fun confirm(matches: Boolean) {19+        decision.complete(matches)20+    }21+22+    internal suspend fun await(): Boolean = decision.await()23+}

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+4 −12
@@ -21,7 +21,10 @@2121 import javax.inject.Inject2222 2323 /**24- * Keeps the pairing/sync listener reachable.24+ * Keeps this device reachable on the relay so an already-paired peer can25+ * reconnect. A browser extension can never listen, so if the phone is not26+ * registered the two devices simply never sync and neither shows an error —27+ * "no connection" and "nothing to sync" look identical.2528  *2629  * A browser extension can never listen for a connection, so the phone has to be2730  * the side that is dialable — and a phone that is only dialable while its app@@ -37,7 +40,6 @@3740 @AndroidEntryPoint3841 class PeachSyncService : Service() {3942 40-    @Inject lateinit var listener: SyncReconnectListener4143     @Inject lateinit var cloudRelayListener: CloudRelayReconnectListener4244     @Inject lateinit var vault: VaultRepository4345     @Inject lateinit var deviceStore: PairedDeviceStore@@ -58,7 +60,6 @@5860     }5961 6062     override fun onDestroy() {61-        listener.stop()6263         cloudRelayListener.stop()6364         scope.cancel()6465         super.onDestroy()@@ -75,15 +76,6 @@7576         try {7677             val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(this))7778             val identity = buildLocalIdentity(this, master)78-            listener.start(scope, keys.secretKey, keys.publicKey, identity) {79-                if (!vault.isUnlocked.value) null else VaultSyncAdapter(80-                    repo = vault,81-                    ourDeviceId = getOrCreateDeviceId(this),82-                    pairedDevices = deviceStore,83-                    pendingVouched = pendingVouchedStore,84-                    syncAlerts = syncAlertStore,85-                )86-            }8779             cloudRelayListener.start(scope, keys.secretKey, keys.publicKey, identity) {8880                 if (!vault.isUnlocked.value) null else VaultSyncAdapter(8981                     repo = vault,

view file

app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+1 −1
@@ -10,7 +10,7 @@1010  * A minimal RFC 6455 server endpoint — just enough of the protocol to talk to a1111  * browser extension's `new WebSocket(...)`.1212  *13- * Android already had a pairing listener ([MdnsPairingServer]), but it spoke13+ * Android already had a pairing listener, but it spoke1414  * raw length-prefixed TCP frames. A browser extension cannot open a raw socket;1515  * `ws://` is the only outbound connection it can make. Since the browser is1616  * always the dialer and the phone always the listener, that asymmetry is the

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+6 −6
@@ -39,7 +39,7 @@3939         ourStaticPublicKey: ByteArray,4040         localIdentity: LocalChirpIdentity,4141         sourceDeps: AdoptionSourceDeps,42-        windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,42+        windowMs: Long = PAIRING_WINDOW_MS,4343     ): Flow<RelayAdoptionEvent> = callbackFlow {4444         val relayUrl = relaySettings.relayUrl()4545         val token = Rendezvous.newPairingToken()@@ -89,11 +89,11 @@8989             } catch (error: kotlinx.coroutines.TimeoutCancellationException) {9090                 trySend(RelayAdoptionEvent.Expired)9191             } catch (error: kotlinx.coroutines.CancellationException) {92-                // stop() cancels this job when the user switches to LAN-only or93-                // leaves the screen. That is not a pairing failure — reporting it94-                // as one raced the LAN listener and replaced the freshly shown95-                // pairing code with "Couldn't pair: StandaloneCoroutine was96-                // cancelled". Cancellation must propagate, never be swallowed.92+                // stop() cancels this job when the user leaves the screen or93+                // restarts pairing. That is not a pairing failure — reporting it94+                // as one raced the new session and replaced the freshly shown95+                // code with "Couldn't pair: StandaloneCoroutine was cancelled".96+                // Cancellation must propagate, never be swallowed.9797                 throw error9898             } catch (error: Throwable) {9999                 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+1 −1
@@ -17,7 +17,7 @@1717         encodedInvitation: String,1818         deps: AdoptionReceiverDeps,1919         confirm: suspend (sas: String) -> Boolean,20-        windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,20+        windowMs: Long = PAIRING_WINDOW_MS,2121     ): PairedDevice {2222         val invitation = parsePairingInvitation(encodedInvitation)2323             ?: throw IllegalArgumentException("invalid_pairing_invitation")

view file

app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+0 −100
@@ -1,100 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.serialization.SerialName4-import kotlinx.serialization.Serializable5-import kotlinx.serialization.json.Json6-import kotlinx.serialization.json.JsonObject7-import kotlinx.serialization.json.JsonPrimitive8-9-/**10- * Browser-to-browser relay frames — the wire addition from11- * `PEACH_SYNC_RELAY_DESIGN.md` §3, made concrete.12- *13- * Two browser extensions can never connect to each other: neither can listen.14- * So a phone already paired with both forwards between them. The phone is a15- * pipe, not a peer — Noise KK for relayed reconnect traffic is end-to-end16- * between the two laptops, and [Payload.payload] is opaque here by design.17- *18- * These frames ride the SAME encrypted channel as ordinary sync traffic: each19- * one is JSON with a `type` discriminator (matching [SyncMessage] and the20- * browser's `sync-types.ts`), encrypted under the phone↔laptop Noise session21- * like everything else. That link encryption is per-hop and is not what22- * protects the payload; the inner end-to-end session is.23- *24- * Direction of each frame:25- *   RELAY_REQUEST      requester → phone26- *   RELAY_ACCEPT       phone → requester   (a channel is now open)27- *   RELAY_UNAVAILABLE  phone → requester   (target not reachable through me)28- *   RELAY_DISABLED     phone → requester   (relaying turned off in Settings)29- *   RELAY_OPEN         phone → target      (someone is relaying to you)30- *   RELAY_FRAME        either way, forwarded verbatim31- *   RELAY_CLOSE        either way, ends the channel32- *33- * RELAY_UNAVAILABLE and RELAY_DISABLED are deliberately distinct: the correct34- * response differs. Disabled means "try a different relay-capable device";35- * unavailable means "the target isn't here right now — retry, or try another36- * relay in case it connects there instead."37- */38-@Serializable39-sealed class RelayMessage {40-    /**41-     * [targetDeviceId] is the destination's Noise static public key as42-     * lowercase hex — the design doc's field name, but keyed on the public key43-     * rather than the `deviceId` string, since the key is what both the phone's44-     * registry and the requester can name unambiguously.45-     */46-    @Serializable47-    @SerialName("RELAY_REQUEST")48-    data class Request(val targetDeviceId: String) : RelayMessage()49-50-    @Serializable51-    @SerialName("RELAY_ACCEPT")52-    data object Accept : RelayMessage()53-54-    @Serializable55-    @SerialName("RELAY_UNAVAILABLE")56-    data object Unavailable : RelayMessage()57-58-    @Serializable59-    @SerialName("RELAY_DISABLED")60-    data object Disabled : RelayMessage()61-62-    /** Tells the target which peer it is now connected to, by public key hex. */63-    @Serializable64-    @SerialName("RELAY_OPEN")65-    data class Open(val peerDeviceId: String) : RelayMessage()66-67-    /** [payload] is base64 of bytes the relay must not interpret. */68-    @Serializable69-    @SerialName("RELAY_FRAME")70-    data class Payload(val payload: String) : RelayMessage()71-72-    @Serializable73-    @SerialName("RELAY_CLOSE")74-    data object Close : RelayMessage()75-}76-77-val RelayJson: Json = Json {78-    classDiscriminator = "type"79-    encodeDefaults = true80-    ignoreUnknownKeys = true81-}82-83-fun RelayMessage.encodeToBytes(): ByteArray =84-    RelayJson.encodeToString(RelayMessage.serializer(), this).toByteArray(Charsets.UTF_8)85-86-/**87- * Returns null when [bytes] is not a relay frame at all — which is how the88- * listener tells "forward this blindly" apart from "this is sync traffic for my89- * own vault". Sniffing the discriminator rather than catching a decode failure90- * keeps a malformed *relay* frame loud (it throws) while an ordinary SYNC_ frame91- * is simply not ours to handle.92- */93-fun decodeRelayMessage(bytes: ByteArray): RelayMessage? {94-    val text = runCatching { String(bytes, Charsets.UTF_8) }.getOrNull() ?: return null95-    val type = runCatching {96-        (RelayJson.parseToJsonElement(text) as? JsonObject)?.get("type") as? JsonPrimitive97-    }.getOrNull()?.content ?: return null98-    if (!type.startsWith("RELAY_")) return null99-    return RelayJson.decodeFromString(RelayMessage.serializer(), text)100-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/RelayHub.kt+0 −163
@@ -1,163 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.sync.Mutex4-import kotlinx.coroutines.sync.withLock5-import javax.inject.Inject6-import javax.inject.Singleton7-8-/** Whether this device will relay for others. Settings-backed; see [RelaySettingsStore]. */9-interface RelayPolicy {10-    fun isRelayEnabled(): Boolean11-}12-13-/**14- * One authenticated peer connection, as seen by the relay.15- *16- * A link is in exactly one mode at a time, which is what keeps the single17- * Noise session per connection safe to write to: while SYNCING the connection's18- * own coroutine is the only writer, and while RELAYING the partner's read loop19- * is. The mutex covers the overlap at the edges (RELAY_OPEN/RELAY_CLOSE).20- */21-class RelayLink internal constructor(22-    val peerKeyHex: String,23-    private val sendPlaintext: suspend (ByteArray) -> Unit,24-) {25-    internal enum class Mode { IDLE, SYNCING, RELAYING }26-27-    private val writeLock = Mutex()28-    internal var mode: Mode = Mode.IDLE29-    internal var partner: RelayLink? = null30-31-    internal suspend fun send(message: RelayMessage) = sendRaw(message.encodeToBytes())32-33-    /** Sends already-encoded plaintext — the forwarding path never re-encodes. */34-    internal suspend fun sendRaw(frame: ByteArray) = writeLock.withLock { sendPlaintext(frame) }35-}36-37-/**38- * Phone-side relay dispatch (`PEACH_SYNC_RELAY_DESIGN.md` §2 and §4).39- *40- * Two laptops can never connect to each other — neither can listen — so a phone41- * paired with both forwards between them. Frames pass through verbatim: the42- * relay decrypts only the per-hop link encryption it is a party to, sees a43- * RELAY_FRAME wrapper, and re-encrypts the same bytes onto the other link. The44- * inner Noise KK session belongs to the two laptops and terminates there.45- *46- * The hub holds every live authenticated connection keyed by the peer's static47- * public key, which is the addressing layer the design calls for — nothing else48- * on the wire identifies a destination.49- *50- * Multiple laptop pairs can be relayed at once (design §4); what is *not*51- * allowed is one link serving two channels, since a RELAY_FRAME carries no52- * channel id and would be ambiguous.53- */54-@Singleton55-class RelayHub @Inject constructor(56-    private val policy: RelayPolicy,57-) {58-    private val lock = Mutex()59-    private val links = mutableMapOf<String, RelayLink>()60-61-    /** Registers an authenticated connection. Call [unregister] when it ends. */62-    suspend fun register(peerKeyHex: String, sendPlaintext: suspend (ByteArray) -> Unit): RelayLink {63-        val link = RelayLink(peerKeyHex.lowercase(), sendPlaintext)64-        lock.withLock { links[link.peerKeyHex] = link }65-        return link66-    }67-68-    suspend fun unregister(link: RelayLink) {69-        val partner = lock.withLock {70-            if (links[link.peerKeyHex] === link) links.remove(link.peerKeyHex)71-            detachLocked(link)72-        }73-        // A relayed peer whose partner vanished would otherwise sit waiting on a74-        // channel that can never deliver another frame.75-        partner?.let { runCatching { it.send(RelayMessage.Close) } }76-    }77-78-    /** Marks a link busy with its own vault's sync, so it is not offered as a relay target. */79-    suspend fun beginSync(link: RelayLink): Boolean = lock.withLock {80-        if (link.mode != RelayLink.Mode.IDLE) return@withLock false81-        link.mode = RelayLink.Mode.SYNCING82-        true83-    }84-85-    suspend fun endSync(link: RelayLink) = lock.withLock {86-        if (link.mode == RelayLink.Mode.SYNCING) link.mode = RelayLink.Mode.IDLE87-    }88-89-    /**90-     * Answers a RELAY_REQUEST. On success both links enter RELAYING and every91-     * later RELAY_FRAME on either is forwarded to the other.92-     */93-    suspend fun onRequest(from: RelayLink, targetKeyHex: String) {94-        if (!policy.isRelayEnabled()) {95-            from.send(RelayMessage.Disabled)96-            return97-        }98-        val target = lock.withLock {99-            val candidate = links[targetKeyHex.lowercase()]100-            if (candidate == null ||101-                candidate === from ||102-                candidate.mode != RelayLink.Mode.IDLE ||103-                from.mode != RelayLink.Mode.IDLE104-            ) {105-                null106-            } else {107-                from.partner = candidate108-                candidate.partner = from109-                from.mode = RelayLink.Mode.RELAYING110-                candidate.mode = RelayLink.Mode.RELAYING111-                candidate112-            }113-        }114-        if (target == null) {115-            from.send(RelayMessage.Unavailable)116-            return117-        }118-        // The target learns first: an ACCEPT that beat the OPEN would invite the119-        // requester to send frames into a peer that has not been told to expect120-        // them.121-        try {122-            target.send(RelayMessage.Open(from.peerKeyHex))123-        } catch (_: Exception) {124-            lock.withLock { detachLocked(from) }125-            from.send(RelayMessage.Unavailable)126-            return127-        }128-        from.send(RelayMessage.Accept)129-    }130-131-    /**132-     * Forwards one frame to the link's partner, byte for byte. Returns false if133-     * there is no channel, which the caller answers with RELAY_CLOSE.134-     */135-    suspend fun forward(from: RelayLink, frame: ByteArray): Boolean {136-        val partner = lock.withLock { from.partner } ?: return false137-        return try {138-            partner.sendRaw(frame)139-            true140-        } catch (_: Exception) {141-            closeChannel(from)142-            false143-        }144-    }145-146-    /** Tears the channel down and tells the other end, if there is one. */147-    suspend fun closeChannel(link: RelayLink) {148-        val partner = lock.withLock { detachLocked(link) }149-        partner?.let { runCatching { it.send(RelayMessage.Close) } }150-    }151-152-    /** Must be called with [lock] held. Returns the detached partner, if any. */153-    private fun detachLocked(link: RelayLink): RelayLink? {154-        val partner = link.partner155-        link.partner = null156-        if (link.mode == RelayLink.Mode.RELAYING) link.mode = RelayLink.Mode.IDLE157-        if (partner != null) {158-            partner.partner = null159-            if (partner.mode == RelayLink.Mode.RELAYING) partner.mode = RelayLink.Mode.IDLE160-        }161-        return partner162-    }163-}

view file at the parent

app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+68 −23
@@ -6,50 +6,95 @@66 import javax.inject.Singleton77 88 /**9- * Whether this device relays sync traffic for other paired devices.9+ * Sync is exactly three named choices (docs/sync-lan-retirement.md, mirroring10+ * the browser's `sync-settings.ts`):1011  *11- * Decided 2026-07-24 (`PEACH_SYNC_RELAY_DESIGN.md` §5): on by default. Two12- * laptops cannot sync with each other at all unless some native device relays,13- * so a phone that had to be configured first would leave that path silently14- * broken for everyone who never opened Settings.12+ *  - [PUBLIC]  — Peach's relay. The default, zero setup.13+ *  - [PRIVATE] — a self-hosted peach-relay this mesh points at.14+ *  - [OFF]     — no sync; the vault moves by Codex/CSV export instead.1515  *16- * Plain SharedPreferences rather than the encrypted store: this is a boolean17- * about this handset's role, not a secret, and it must not travel to other16+ * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and17+ * no "local network only" opt-out.18+ */19+enum class SyncMode {20+    PUBLIC,21+    PRIVATE,22+    OFF,23+    ;24+25+    fun wire(): String = when (this) {26+        PUBLIC -> "public"27+        PRIVATE -> "private"28+        OFF -> "off"29+    }30+31+    companion object {32+        fun parse(raw: String?): SyncMode? = when (raw) {33+            "public" -> PUBLIC34+            "private" -> PRIVATE35+            "off" -> OFF36+            else -> null37+        }38+    }39+}40+41+/**42+ * This handset's sync configuration. Plain SharedPreferences rather than the43+ * encrypted store: none of it is a secret, and it must not travel to other1844  * devices the way a vault setting would.1945  */2046 @Singleton2147 class RelaySettingsStore @Inject constructor(2248     @ApplicationContext context: Context,23-) : RelayPolicy {49+) {2450     private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)2551 26-    override fun isRelayEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, true)52+    fun syncMode(): SyncMode {53+        SyncMode.parse(prefs.getString(KEY_MODE, null))?.let { return it }54+        // Migration. Someone who set lanOnly chose "no relay involved", and LAN55+        // is now gone, so the honest landing spot is OFF rather than silently56+        // enrolling them in a relay they opted out of. Everyone else lands on57+        // public/private by whether they had configured a custom URL.58+        return when {59+            prefs.getBoolean(LEGACY_KEY_LAN_ONLY, false) -> SyncMode.OFF60+            storedRelayUrl() != Rendezvous.DEFAULT_RELAY_URL -> SyncMode.PRIVATE61+            else -> SyncMode.PUBLIC62+        }63+    }2764 28-    fun setRelayEnabled(enabled: Boolean) {29-        prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()65+    fun setSyncMode(mode: SyncMode) {66+        prefs.edit().putString(KEY_MODE, mode.wire()).apply()3067     }3168 32-    fun relayUrl(): String = prefs.getString(KEY_URL, null)33-        ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }34-        ?: Rendezvous.DEFAULT_RELAY_URL69+    fun isSyncEnabled(): Boolean = syncMode() != SyncMode.OFF3570 36-    fun setRelayUrl(input: String): String {71+    /** The relay in force: always the default under [SyncMode.PUBLIC]. */72+    fun relayUrl(): String =73+        if (syncMode() == SyncMode.PUBLIC) Rendezvous.DEFAULT_RELAY_URL else storedRelayUrl()74+75+    /**76+     * Point this mesh at a self-hosted relay. Implies [SyncMode.PRIVATE] —77+     * offering a custom relay and not switching to it would be a trap.78+     */79+    fun setPrivateRelayUrl(input: String): String {3780         val normalized = Rendezvous.normalizeRelayUrl(input)38-        prefs.edit().putString(KEY_URL, normalized).apply()81+        prefs.edit()82+            .putString(KEY_URL, normalized)83+            .putString(KEY_MODE, SyncMode.PRIVATE.wire())84+            .apply()3985         return normalized4086     }4187 42-    fun isLanOnly(): Boolean = prefs.getBoolean(KEY_LAN_ONLY, false)88+    private fun storedRelayUrl(): String = prefs.getString(KEY_URL, null)89+        ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }90+        ?: Rendezvous.DEFAULT_RELAY_URL4391 44-    fun setLanOnly(lanOnly: Boolean) {45-        prefs.edit().putBoolean(KEY_LAN_ONLY, lanOnly).apply()46-    }47-4892     companion object {4993         const val PREFS_FILE = "peach_relay_settings"50-        const val KEY_ENABLED = "relay_enabled"5194         const val KEY_URL = "relay_url"52-        const val KEY_LAN_ONLY = "lan_only"95+        const val KEY_MODE = "sync_mode"96+        /** Retired `lanOnly` flag, read once for migration and never written. */97+        const val LEGACY_KEY_LAN_ONLY = "lan_only"5398 5499         fun readRelayUrl(context: Context): String =55100             context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)

view file

app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+8 −0
@@ -128,3 +128,11 @@128128         return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }129129     }130130 }131+132+/**133+ * How long a first-pair invitation stays live. Five minutes: long enough to134+ * walk to the other device, short enough that a code does not sit valid in a135+ * pocket. Lived on the retired LAN `CodePairingServer` until relay-only136+ * pairing (docs/sync-lan-retirement.md); it was never LAN-specific.137+ */138+const val PAIRING_WINDOW_MS = 5L * 60 * 1000

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+0 −270
@@ -1,270 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.CoroutineScope4-import kotlinx.coroutines.Dispatchers5-import kotlinx.coroutines.isActive6-import kotlinx.coroutines.launch7-import kotlinx.coroutines.withContext8-import org.json.JSONObject9-import java.net.ServerSocket10-import java.net.Socket11-import java.security.MessageDigest12-import javax.inject.Inject13-import javax.inject.Singleton14-15-/**16- * The everyday sync path: already-paired devices reconnecting without a code.17- *18- * The pairing window ([CodePairingServer]) is a first-join ceremony and closes19- * after one success. This is the listener that stays up, and it is the reason20- * [PeachSyncService] exists — a browser extension can never listen, so if the21- * phone is not reachable, two devices simply never sync and neither shows an22- * error, because "no connection" and "nothing to sync" look identical.23- *24- * Reconnect uses Noise KK. The plaintext PEACH_RECONNECT selector is an opaque,25- * rotating capability used only to find an already-paired local record before26- * KK; it reveals no public identity and grants no trust. An unknown selector is27- * dropped, and a KK failure never retries the weaker first-pair NK pattern.28- *29- * The port is fixed rather than OS-assigned because the browser reconnects to a30- * cached address; an ephemeral port would be stale the moment the service31- * restarted, and the reconnect would fail in the one way that looks exactly32- * like the phone being offline.33- */34-@Singleton35-class SyncReconnectListener @Inject constructor(36-    private val deviceStore: PairedDeviceRegistry,37-    private val relayHub: RelayHub,38-) {39-    private var serverSocket: ServerSocket? = null40-41-    companion object {42-        /**43-         * Advertised to peers in PAIR_IDENTITY's `wsPort` so they cache it.44-         * Picked in the dynamic/private range to avoid colliding with anything45-         * a user is likely to be running.46-         */47-        const val SYNC_PORT = 4914248-    }49-50-    /** The port actually bound, or null if the listener is not running. */51-    val boundPort: Int? get() = serverSocket?.localPort52-53-    /** [port] is a seam for tests; peers only ever cache [SYNC_PORT]. */54-    fun start(55-        scope: CoroutineScope,56-        ourStaticPrivateKey: ByteArray,57-        ourStaticPublicKey: ByteArray,58-        localIdentity: LocalChirpIdentity,59-        port: Int = SYNC_PORT,60-        vaultFactory: () -> ChirpSyncVaultAdapter?,61-    ) {62-        if (serverSocket != null) return63-        scope.launch(Dispatchers.IO) {64-            val socket = try {65-                ServerSocket(port)66-            } catch (_: Exception) {67-                // Port taken — better to be unreachable than to bind somewhere68-                // no peer has cached and pretend we are listening.69-                return@launch70-            }71-            serverSocket = socket72-            while (isActive && !socket.isClosed) {73-                val client = runCatching { socket.accept() }.getOrNull() ?: break74-                launch {75-                    runCatching {76-                        handle(client, ourStaticPrivateKey, ourStaticPublicKey, localIdentity, vaultFactory)77-                    }78-                }79-            }80-        }81-    }82-83-    fun stop() {84-        runCatching { serverSocket?.close() }85-        serverSocket = null86-    }87-88-    private suspend fun handle(89-        client: Socket,90-        ourStaticPrivateKey: ByteArray,91-        ourStaticPublicKey: ByteArray,92-        localIdentity: LocalChirpIdentity,93-        vaultFactory: () -> ChirpSyncVaultAdapter?,94-    ) = withContext(Dispatchers.IO) {95-        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())96-        var noiseSession: NoiseNKSession? = null97-        try {98-            if (!ws.accept()) return@withContext99-100-            val selectorBytes = (ws.receive() as? WsMessage.Binary)?.bytes101-                ?: return@withContext102-            val selector = runCatching {103-                JSONObject(String(selectorBytes, Charsets.UTF_8))104-            }.getOrNull() ?: return@withContext105-            if (selector.optString("type") != "PEACH_RECONNECT") return@withContext106-            val rendezvousId = selector.optString("rendezvous_id")107-            val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)108-                ?: return@withContext109-            // Part 5 §C: revocation drops the registry row, so resolvePeer110-            // already fails for a revoked device. This second check is the one111-            // that actually has to hold — the phone is the listening side, so112-            // it is what a stolen laptop dials from anywhere over the relay.113-            // Never let a row that somehow survived re-open that door.114-            if (deviceStore.listRevocations().any { it.deviceId == known.deviceId }) {115-                return@withContext116-            }117-            val initiatorKeyHex = known.longTermPublicKeyHex ?: return@withContext118-            val initiatorKey = hexToPubkey(initiatorKeyHex)119-120-            val session = noiseKKResponder(121-                ourStaticPrivateKey,122-                ourStaticPublicKey,123-                initiatorKey,124-                { frame -> ws.send(frame) },125-                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },126-            )127-            noiseSession = session128-129-            val theirIdentity = parsePairIdentityFrame(130-                SyncPadding.unpad(131-                    session.decrypt(132-                        (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),133-                    ),134-                ),135-            )136-137-            if (!identityMatchesAuthenticatedPeer(known, theirIdentity, initiatorKey)) {138-                return@withContext139-            }140-141-            ws.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(localIdentity))))142-143-            // Refresh the reconnect hint — phones and laptops change address144-            // across network joins, and a stale cache is the usual reason a145-            // reconnect quietly stops working.146-            deviceStore.upsert(147-                known.copy(148-                    lastKnownIp = client.inetAddress?.hostAddress ?: known.lastKnownIp,149-                    lastSyncedAt = System.currentTimeMillis(),150-                ),151-            )152-153-            // Registered by public key: that key is the only address a relay154-            // request can name (see RelayHub).155-            val link = relayHub.register(theirIdentity.longTermPublicKeyHex) { plaintext ->156-                ws.send(session.encrypt(SyncPadding.pad(plaintext)))157-            }158-            try {159-                dispatch(link, session, ws, known, initiatorKey, vaultFactory)160-            } finally {161-                relayHub.unregister(link)162-            }163-        } finally {164-            noiseSession?.close()165-            runCatching { client.close() }166-        }167-    }168-169-    /**170-     * The multiplexer: one authenticated connection carries either this phone's171-     * own vault sync or traffic to forward blindly to another paired device, and172-     * nothing before this point distinguishes them.173-     *174-     * The connection stays in this loop after a sync round rather than closing,175-     * because a laptop can only be relayed *to* while it is connected — a phone176-     * that hung up the moment sync finished would have no one to forward to.177-     */178-    private suspend fun dispatch(179-        link: RelayLink,180-        session: NoiseNKSession,181-        ws: WebSocketConnection,182-        authenticatedPeer: PairedDevice,183-        authenticatedPeerKey: ByteArray,184-        vaultFactory: () -> ChirpSyncVaultAdapter?,185-    ) {186-        while (true) {187-            val frame = SyncPadding.unpad(188-                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: return),189-            )190-            when (val relay = decodeRelayMessage(frame)) {191-                null -> {192-                    // Ours to answer. Refusing while relaying is not a policy193-                    // choice: the same Noise session is already carrying someone194-                    // else's channel, and interleaving would corrupt both.195-                    if (!relayHub.beginSync(link)) return196-                    try {197-                        val vault = vaultFactory() ?: return198-                        vault.bindAuthenticatedPeer(authenticatedPeer)199-                        runChirpSyncResponder(200-                            webSocketSyncSession(201-                                session,202-                                ws,203-                                authenticatedPeerKey,204-                                pushback = frame,205-                            ),206-                            vault,207-                        )208-                    } finally {209-                        relayHub.endSync(link)210-                    }211-                }212-                is RelayMessage.Request -> relayHub.onRequest(link, relay.targetDeviceId)213-                is RelayMessage.Payload -> if (!relayHub.forward(link, frame)) link.send(RelayMessage.Close)214-                RelayMessage.Close -> relayHub.closeChannel(link)215-                // The rest are phone→peer answers. A peer sending one back is216-                // either confused or probing; either way there is nothing to do.217-                else -> {}218-            }219-        }220-    }221-222-    /**223-     * A rotated key on a known device id is a different device, or an attempt224-     * to become one. Either way it is not this peer, and re-pairing is the225-     * only correct route back.226-     */227-}228-229-internal fun identityMatchesAuthenticatedPeer(230-    known: PairedDevice,231-    presented: PairIdentity,232-    authenticatedKey: ByteArray,233-): Boolean {234-    val expected = known.longTermPublicKeyHex ?: return false235-    return known.deviceId == presented.deviceId &&236-        CryptoCompare.hex(expected, presented.longTermPublicKeyHex) &&237-        MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))238-}239-240-/**241- * Bridges the sync coordinator's frame IO onto a WebSocket.242- *243- * [pushback] is the frame the relay dispatcher had to read and decrypt before it244- * could tell whose traffic this was — the coordinator's first `receive` gets it245- * back, or SYNC_HELLO would be missing and every reconnect would time out.246- */247-internal fun webSocketSyncSession(248-    session: NoiseNKSession,249-    ws: WebSocketConnection,250-    remoteStaticPublicKey: ByteArray,251-    pushback: ByteArray? = null,252-): ChirpSyncSession {253-    var pending = pushback254-    return ChirpSyncSession(255-        remoteStaticPublicKey = remoteStaticPublicKey.copyOf(),256-        send = { plaintext -> ws.send(session.encrypt(plaintext)) },257-        receive = {258-            pending?.also { pending = null } ?: session.decrypt(259-                (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed"),260-            )261-        },262-        close = {263-            try {264-                session.close()265-            } finally {266-                ws.closeSilently()267-            }268-        },269-    )270-}

view file at the parent

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+0 −41
@@ -109,47 +109,6 @@109109                     PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110110                         clipboard.setText(AnnotatedString(s.invitation))111111                     }112-                    Spacer(Modifier.height(PeachSpacing.sm))113-                    PillButton("Use local network only", peachColors.T2, filled = false) {114-                        vm.useLanOnly(true)115-                    }116-                }117-118-                is CodePairUiState.ShowingCode -> {119-                    Headline(120-                        "Type this on your computer",121-                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +122-                            "Both devices need to be on the same Wi-Fi.",123-                    )124-                    Spacer(Modifier.height(PeachSpacing.lg))125-                    Text(126-                        text = s.code,127-                        color = LocalPeachAccent.current,128-                        fontFamily = PeachType.Mono,129-                        fontSize = 26.sp,130-                        letterSpacing = 2.sp,131-                        textAlign = TextAlign.Center,132-                        modifier = Modifier.fillMaxWidth(),133-                    )134-                    // The code carries this phone's address, so a VPN that135-                    // routes the computer elsewhere makes it undialable. The136-                    // failure is a silent timeout with nothing to look at, so137-                    // say it here rather than let it be debugged from scratch.138-                    Spacer(Modifier.height(PeachSpacing.md))139-                    Text(140-                        text = "The code contains this phone's address on your network (${s.ip}). " +141-                            "If either device is on a VPN, turn it off for a moment — the computer " +142-                            "won't be able to reach this address through it.",143-                        color = peachColors.T2,144-                        fontFamily = PeachType.Body,145-                        fontSize = 11.sp,146-                        textAlign = TextAlign.Center,147-                        modifier = Modifier.fillMaxWidth(),148-                    )149-                    Spacer(Modifier.height(PeachSpacing.sm))150-                    PillButton("Use secure relay instead", peachColors.T2, filled = false) {151-                        vm.useLanOnly(false)152-                    }153112                 }154113 155114                 is CodePairUiState.ConfirmSas -> {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+11 −59
@@ -6,13 +6,12 @@66 import androidx.lifecycle.ViewModel77 import androidx.lifecycle.viewModelScope88 import com.peachpasswords.sync.BatteryExemption9-import com.peachpasswords.sync.CodePairingServer10-import com.peachpasswords.sync.PairingEvent119 import com.peachpasswords.sync.PairingSession1210 import com.peachpasswords.sync.PeachSyncService1311 import com.peachpasswords.sync.RelayAdoptionEvent1412 import com.peachpasswords.sync.RelayAdoptionHost1513 import com.peachpasswords.sync.RelaySettingsStore14+import com.peachpasswords.sync.SyncMode1615 import com.peachpasswords.sync.AdoptionSourceDeps1716 import com.peachpasswords.sync.AdoptionFrame1817 import com.peachpasswords.sync.LocalChirpIdentity@@ -34,16 +33,16 @@3433 import javax.inject.Inject3534 3635 /**37- * The phone half of code pairing: show a code, wait to be dialed.36+ * The phone half of pairing: occupy a relay room, show the invitation, wait to37+ * be scanned.3838  *39- * The browser drives the conversation from here on — it is always the dialer40- * (see [CodePairingServer] for the wire contract), so there is nothing for this41- * screen to do but publish the code and then present the SAS for comparison.39+ * There is one transport. LAN pairing codes are retired along with LAN sync40+ * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and41+ * then presents the SAS for comparison.4242  */4343 sealed interface CodePairUiState {4444     data object Starting : CodePairUiState4545     data class ShowingInvitation(val invitation: String) : CodePairUiState46-    data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState4746     data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState4847     data class Paired(val deviceName: String) : CodePairUiState4948     data object Expired : CodePairUiState@@ -53,7 +52,6 @@5352 @HiltViewModel5453 class CodePairViewModel @Inject constructor(5554     private val vault: VaultRepository,56-    private val server: CodePairingServer,5755     private val relayHost: RelayAdoptionHost,5856     private val relaySettings: RelaySettingsStore,5957     private val deviceStore: PairedDeviceStore,@@ -83,54 +81,16 @@8381             _state.value = CodePairUiState.Failed("vault_locked")8482             return8583         }84+        if (relaySettings.syncMode() == SyncMode.OFF) {85+            _state.value = CodePairUiState.Failed("sync_disabled")86+            return87+        }8688         PeachSyncService.start(appContext)8789 8890         val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))8991         val identity = buildLocalIdentity(appContext, master)9092         VaultCrypto.zero(master)91-        val sourceDeps = sourceDeps(identity)92-93-        if (!relaySettings.isLanOnly()) {94-            startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)95-            return96-        }97-98-        // The handler blocks inside the live connection while the user compares99-        // digits, so the SAS has to reach the UI out of band rather than as the100-        // connection's result.101-        server.confirmationSink = { pending ->102-            _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)103-        }104-105-        collectJob = viewModelScope.launch {106-            try {107-                server.start(108-                    viewModelScope,109-                    keys.secretKey,110-                    keys.publicKey,111-                    identity,112-                    adoptionSourceDeps = sourceDeps,113-                ).collect { event ->114-                    _state.value = when (event) {115-                        is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)116-                        is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)117-                        PairingEvent.Expired -> CodePairUiState.Expired118-                        is PairingEvent.Rejected -> {119-                            // Nothing else records why a pairing attempt died, and120-                            // the on-screen copy is deliberately vague. Without this121-                            // a failure on someone else's phone is undiagnosable.122-                            if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")123-                            CodePairUiState.Failed(event.reason)124-                        }125-                        // Delivered via confirmationSink; the flow only sees the outcome.126-                        is PairingEvent.AwaitingConfirmation -> _state.value127-                    }128-                }129-            } finally {130-                VaultCrypto.zero(keys.secretKey)131-                VaultCrypto.zero(keys.publicKey)132-            }133-        }93+        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))13494     }13595 13696     private fun startRelay(@@ -218,22 +178,14 @@218178         collectJob?.cancel()219179         collectJob = null220180         relayHost.stop()221-        server.stop()222181         _state.value = CodePairUiState.Starting223182         start()224183     }225184 226-    fun useLanOnly(lanOnly: Boolean) {227-        relaySettings.setLanOnly(lanOnly)228-        restart()229-    }230-231185     fun stop() {232186         collectJob?.cancel()233187         collectJob = null234188         relayHost.stop()235-        server.confirmationSink = null236-        server.stop()237189     }238190 239191     override fun onCleared() {

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+90 −54
@@ -30,6 +30,7 @@3030 import androidx.compose.runtime.getValue3131 import com.peachpasswords.feedback.FeedbackState3232 import com.peachpasswords.shared.PeachClipboard33+import com.peachpasswords.sync.SyncMode3334 import com.peachpasswords.sync.SyncStatus3435 import com.peachpasswords.ui.theme.LocalPeachAccent3536 import com.peachpasswords.vault.DuressAction@@ -660,9 +661,8 @@660661     val pairedDevices by syncVm.pairedDevices.collectAsState()661662     val pendingVouched by syncVm.pendingVouched.collectAsState()662663     val syncStatus by syncVm.status.collectAsState()663-    val relayEnabled by syncVm.relayEnabled.collectAsState()664664     val configuredRelayUrl by syncVm.relayUrl.collectAsState()665-    val lanOnly by syncVm.lanOnly.collectAsState()665+    val syncMode by syncVm.syncMode.collectAsState()666666     val scope = rememberCoroutineScope()667667     var confirmUnpair by remember { mutableStateOf<String?>(null) }668668     var rotateConfirmOpen by remember { mutableStateOf(false) }@@ -770,64 +770,69 @@770770         }771771     }772772 773-    SubLabel("CONNECTION", fg)774-    RowLine(fg) {775-        Column(Modifier.weight(1f)) {776-            Text("Use local network only (no relay)", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)777-            Text(778-                "Sync will only work when your devices share the same local network. " +779-                    "It won't work over VPN or when your devices are in different locations.",780-                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,781-                modifier = Modifier.padding(top = 2.dp),773+    // Three named choices, not a toggle with an implied default. LAN sync is774+    // retired (docs/sync-lan-retirement.md), so "no relay" now means no sync775+    // rather than a peer-to-peer mode that never reliably worked. The776+    // phone-as-relay-for-two-browsers mechanism went with it: browsers dial the777+    // real relay directly now, so this handset has no forwarding role.778+    SubLabel("HOW THIS VAULT SYNCS", fg)779+    SyncModeRow(780+        title = "Public relay",781+        detail = "Peach's own relay. Works anywhere, nothing to set up. It never sees " +782+            "your vault — everything is encrypted before it leaves this device.",783+        selected = syncMode == SyncMode.PUBLIC,784+        fg = fg,785+    ) { syncVm.setSyncMode(SyncMode.PUBLIC) }786+    SyncModeRow(787+        title = "Private relay",788+        detail = "Your own peach-relay, on your own domain. Same open-source code.",789+        selected = syncMode == SyncMode.PRIVATE,790+        fg = fg,791+    ) { syncVm.setSyncMode(SyncMode.PRIVATE) }792+793+    if (syncMode == SyncMode.PRIVATE) {794+        Text(795+            "RELAY URL",796+            color = fg.copy(alpha = 0.6f),797+            fontFamily = PeachType.Mono,798+            fontSize = 8.sp,799+            letterSpacing = 0.22.em,800+            modifier = Modifier.padding(top = 8.dp),801+        )802+        Box(803+            Modifier.fillMaxWidth().padding(top = 6.dp)804+                .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))805+                .padding(horizontal = 12.dp, vertical = 10.dp),806+        ) {807+            BasicTextField(808+                value = relayUrlDraft,809+                onValueChange = { relayUrlDraft = it; relayUrlStatus = null },810+                singleLine = true,811+                textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),812+                cursorBrush = SolidColor(fg),813+                modifier = Modifier.fillMaxWidth(),782814             )783815         }784-        Toggle(lanOnly, fg) { syncVm.setLanOnly(it) }785-    }786-    Text(787-        "MESH RELAY URL",788-        color = fg.copy(alpha = 0.6f),789-        fontFamily = PeachType.Mono,790-        fontSize = 8.sp,791-        letterSpacing = 0.22.em,792-    )793-    Box(794-        Modifier.fillMaxWidth().padding(top = 6.dp)795-            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))796-            .padding(horizontal = 12.dp, vertical = 10.dp),797-    ) {798-        BasicTextField(799-            value = relayUrlDraft,800-            onValueChange = { relayUrlDraft = it; relayUrlStatus = null },801-            singleLine = true,802-            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),803-            cursorBrush = SolidColor(fg),804-            modifier = Modifier.fillMaxWidth(),805-        )806-    }807-    RowLine(fg) {808-        ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)809-        SubtleButton("save", fg) {810-            relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {811-                relayUrlDraft = syncVm.relayUrl.value812-                "Saved"813-            } else {814-                "Use a valid wss:// relay URL"816+        RowLine(fg) {817+            ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)818+            SubtleButton("save", fg) {819+                relayUrlStatus = if (syncVm.setPrivateRelayUrl(relayUrlDraft)) {820+                    relayUrlDraft = syncVm.relayUrl.value821+                    "Saved"822+                } else {823+                    "Use a valid wss:// relay URL"824+                }815825             }816826         }817827     }818828 819-    SubLabel("RELAYING FOR PEERS", fg)820-    RowLine(fg) {821-        Column(Modifier.weight(1f)) {822-            Text("Relay for other devices", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)823-            Text(824-                "Two browsers can't reach each other directly, so this phone passes their encrypted sync between them. It can't read any of it.",825-                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,826-                modifier = Modifier.padding(top = 2.dp),827-            )828-        }829-        Toggle(relayEnabled, fg) { syncVm.setRelayEnabled(it) }830-    }829+    SyncModeRow(830+        title = "No sync",831+        detail = "This device keeps to itself. Move your vault by hand with Codex or " +832+            "CSV export and import.",833+        selected = syncMode == SyncMode.OFF,834+        fg = fg,835+    ) { syncVm.setSyncMode(SyncMode.OFF) }831836 832837     if (pendingVouched.isNotEmpty()) {833838         SubLabel("PENDING DEVICES", fg)@@ -1345,5 +1350,36 @@13451350         contentAlignment = Alignment.Center,13461351     ) {13471352         Text(label, color = peachColors.Breach, fontFamily = PeachType.Mono, fontSize = 11.sp)1353+    }1354+}1355+1356+/** One choice in the three-way sync selector. */1357+@Composable1358+private fun SyncModeRow(1359+    title: String,1360+    detail: String,1361+    selected: Boolean,1362+    fg: Color,1363+    onSelect: () -> Unit,1364+) {1365+    RowLine(fg, Modifier.clickable(onClick = onSelect)) {1366+        Column(Modifier.weight(1f)) {1367+            Text(title, color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)1368+            Text(1369+                detail,1370+                color = fg.copy(alpha = 0.7f),1371+                fontFamily = PeachType.Body,1372+                fontSize = 11.sp,1373+                lineHeight = 16.sp,1374+                modifier = Modifier.padding(top = 2.dp),1375+            )1376+        }1377+        Text(1378+            if (selected) "●" else "○",1379+            color = if (selected) fg else fg.copy(alpha = 0.45f),1380+            fontFamily = PeachType.Mono,1381+            fontSize = 14.sp,1382+            modifier = Modifier.padding(start = 10.dp),1383+        )13481384     }13491385 }

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+25 −67
@@ -8,11 +8,10 @@88 import com.peachpasswords.sync.PairedDeviceStore99 import com.peachpasswords.sync.PendingVouchedDevice1010 import com.peachpasswords.sync.PendingVouchedDeviceStore11-import com.peachpasswords.sync.DiscoveredPeer12-import com.peachpasswords.sync.SyncReconnectListener1311 import com.peachpasswords.sync.hexToPubkey14-import com.peachpasswords.sync.PairingDiscoveryClient12+import com.peachpasswords.sync.PeachSyncService1513 import com.peachpasswords.sync.RelaySettingsStore14+import com.peachpasswords.sync.SyncMode1615 import com.peachpasswords.sync.SyncAlertStore1716 import com.peachpasswords.sync.SyncStatus1817 import com.peachpasswords.sync.VaultSyncAdapter@@ -41,7 +40,10 @@4140  *   4. mDNS peer discovery — a paired device appears on the LAN.4241  *4342  * Discovery, the WebSocket server role, and the paired-device store are wired44- * through [PairingDiscoveryClient], the live Noise KK reconnect dial path.43+ * Under the relay-only model this device WAITS in its rendezvous rooms as the44+ * responder and the peer dials in, so "sync now" makes sure the listener is up45+ * rather than dialling anyone. A browser extension cannot hold a relay room46+ * open, so it has to be the dialler. See docs/sync-lan-retirement.md.4547  */4648 @HiltViewModel4749 class SyncViewModel @Inject constructor(@@ -49,7 +51,6 @@4951     private val vault: VaultRepository,5052     private val deviceStore: PairedDeviceStore,5153     private val pendingVouchedStore: PendingVouchedDeviceStore,52-    private val discoveryClient: PairingDiscoveryClient,5354     private val syncAlertStore: SyncAlertStore,5455     private val relaySettings: RelaySettingsStore,5556 ) : ViewModel() {@@ -57,30 +58,27 @@5758     private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)5859     val status: StateFlow<SyncStatus> = _status.asStateFlow()5960 60-    /** Whether this phone forwards sync traffic between other paired devices. */61-    private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())62-    val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()6361     private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())6462     val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()65-    private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())66-    val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6763 68-    fun setRelayEnabled(on: Boolean) {69-        relaySettings.setRelayEnabled(on)70-        _relayEnabled.value = on71-    }7264 73-    fun setRelayUrl(input: String): Boolean = runCatching {74-        val normalized = relaySettings.setRelayUrl(input)75-        deviceStore.setRelayUrlForAll(normalized)76-        _relayUrl.value = normalized77-    }.isSuccess65+    private val _syncMode = MutableStateFlow(relaySettings.syncMode())66+    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7867 79-    fun setLanOnly(on: Boolean) {80-        relaySettings.setLanOnly(on)81-        _lanOnly.value = on68+    fun setSyncMode(mode: SyncMode) {69+        relaySettings.setSyncMode(mode)70+        _syncMode.value = relaySettings.syncMode()71+        _relayUrl.value = relaySettings.relayUrl()8272     }8373 74+    /** Saving a URL implies private mode. Returns false on an invalid URL. */75+    fun setPrivateRelayUrl(input: String): Boolean = runCatching {76+        _relayUrl.value = relaySettings.setPrivateRelayUrl(input)77+        _syncMode.value = relaySettings.syncMode()78+        true79+    }.getOrDefault(false)80+81+8482     /** Live list of trusted peers, backing the Sync settings card device list (C7). */8583     val pairedDevices: StateFlow<List<PairedDevice>> =8684         deviceStore.observeAll()@@ -152,54 +150,14 @@152150             _status.value = SyncStatus.Error("vault_locked")153151             return154152         }155-        val peers = pairedDevices.value156-        if (peers.isEmpty()) {153+        if (pairedDevices.value.isEmpty()) {157154             _status.value = SyncStatus.Offline158155             return159156         }160-        val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }161-        _status.value = SyncStatus.Syncing162-        viewModelScope.launch {163-            try {164-                val identity = buildLocalIdentity(context, master)165-                val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)166-                try {167-                    peers.forEach { dev ->168-                        // The listener refreshes lastKnownIp on every inbound169-                        // reconnect, so this is usually still valid and saves a170-                        // discovery round — and works when mDNS does not.171-                        val cached = dev.lastKnownIp?.let { ip ->172-                            DiscoveredPeer(173-                                deviceId = dev.deviceId,174-                                deviceName = dev.deviceName ?: dev.deviceId,175-                                host = ip,176-                                port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,177-                                remoteStaticPublicKey = dev.longTermPublicKeyHex178-                                    ?.takeIf { it.length == 64 }179-                                    ?.let(::hexToPubkey)180-                                    ?: return@forEach,181-                            )182-                        }183-                        runCatching {184-                            discoveryClient.syncWithKnownDevice(185-                                deviceId = dev.deviceId,186-                                localIdentity = identity,187-                                localStaticPrivateKey = localKeys.secretKey,188-                                vaultFactory = ::buildAdapter,189-                                cached = cached,190-                            )191-                        }192-                    }193-                } finally {194-                    VaultCrypto.zero(localKeys.secretKey)195-                }196-                _status.value = SyncStatus.LastSynced(System.currentTimeMillis())197-            } catch (e: Exception) {198-                _status.value = SyncStatus.Error(e.message ?: "sync_failed")199-            } finally {200-                VaultCrypto.zero(master)201-            }202-        }157+        // Make sure we are registered on the relay and reachable. The peer158+        // performs the dial; there is nothing to initiate from here.159+        PeachSyncService.start(context)160+        _status.value = SyncStatus.LastSynced(System.currentTimeMillis())203161     }204162 205163     /**

view file

app/src/test/java/com/peachpasswords/sync/C7ChirpTransportTest.kt+36 −15
@@ -10,6 +10,7 @@1010 import org.junit.Assert.assertEquals1111 import org.junit.Assert.assertNotNull1212 import org.junit.Assert.assertThrows13+import org.junit.Assert.assertFalse1314 import org.junit.Assert.assertTrue1415 import org.junit.Test1516 import java.io.PipedInputStream@@ -176,28 +177,48 @@176177     }177178 178179     @Test179-    fun `reconnect discovery cannot replace the locally paired responder key`() {180+    fun `a presented identity cannot replace the locally paired key`() {181+        // Was `reconnect discovery cannot replace the locally paired responder182+        // key`, which pinned a LAN-discovered endpoint. Discovery is gone with183+        // LAN sync (docs/sync-lan-retirement.md), but the property it protected184+        // is not: a peer's self-asserted PAIR_IDENTITY must never override the185+        // paired record, and is only believed when it matches the static key186+        // Noise actually authenticated.180187         val (_, storedPublic) = newKeyPair()181-        val (_, advertisedPublic) = newKeyPair()188+        val (_, spoofedPublic) = newKeyPair()182189         val paired = device("phone").copy(183190             longTermPublicKeyHex = pubkeyToHex(storedPublic),184191         )185-        val advertised = DiscoveredPeer(192+193+        val honest = PairIdentity(186194             deviceId = paired.deviceId,187-            deviceName = "Spoofed endpoint",188-            host = "192.0.2.1",189-            port = 49142,190-            remoteStaticPublicKey = advertisedPublic,195+            deviceName = "Phone",196+            deviceType = DeviceType.ANDROID,197+            longTermPublicKeyHex = pubkeyToHex(storedPublic),198+            signingPublicKeyHex = null,199+            wsPort = null,200+            relayUrl = null,191201         )202+        assertTrue(identityMatchesAuthenticatedPeer(paired, honest, storedPublic))192203 193-        val pinned = pinDiscoveredPeerToPairedState(paired, advertised)194-195-        assertArrayEquals(storedPublic, pinned.remoteStaticPublicKey)196-        assertThrows(IllegalArgumentException::class.java) {197-            pinDiscoveredPeerToPairedState(204+        // Claims another device's id.205+        assertFalse(206+            identityMatchesAuthenticatedPeer(198207                 paired,199-                advertised.copy(deviceId = "different-device"),200-            )201-        }208+                honest.copy(deviceId = "different-device"),209+                storedPublic,210+            ),211+        )212+        // Advertises a key we never paired with.213+        assertFalse(214+            identityMatchesAuthenticatedPeer(215+                paired,216+                honest.copy(longTermPublicKeyHex = pubkeyToHex(spoofedPublic)),217+                spoofedPublic,218+            ),219+        )220+        // Claims the paired key while Noise authenticated a different one — the221+        // spoof this anchoring exists to stop.222+        assertFalse(identityMatchesAuthenticatedPeer(paired, honest, spoofedPublic))202223     }203224 }

view file

app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+0 −276
@@ -1,276 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.CoroutineScope4-import kotlinx.coroutines.Dispatchers5-import kotlinx.coroutines.SupervisorJob6-import kotlinx.coroutines.launch7-import kotlinx.coroutines.runBlocking8-import okhttp3.OkHttpClient9-import okhttp3.Request10-import okhttp3.Response11-import okhttp3.WebSocket12-import okhttp3.WebSocketListener13-import okio.ByteString.Companion.toByteString14-import org.json.JSONObject15-import org.junit.Assert.assertEquals16-import org.junit.Assert.assertTrue17-import org.junit.Test18-import java.util.concurrent.CompletableFuture19-import java.util.concurrent.TimeUnit20-21-/**22- * Covers the two behaviours that are easy to get quietly wrong: a bad token23- * must be refused with no reply at all, and an abandoned code must stop working.24- * Both are invisible when broken — a leaky token check still pairs happily with25- * the right code, and a window that never closes looks identical to one that26- * does until months later.27- */28-class CodePairingServerTest {29-30-    private val identity = LocalChirpIdentity(31-        deviceId = "test-device",32-        deviceName = "Test Phone",33-        deviceType = DeviceType.ANDROID,34-        longTermPublicKeyHex = "00".repeat(32),35-        signingPublicKeyHex = "00".repeat(32),36-    )37-38-    /** Enough of the registry for pairing; the real store needs Android crypto. */39-    private class FakeRegistry : PairedDeviceRegistry {40-        val devices = mutableListOf<PairedDevice>()41-        override fun getAll(): List<PairedDevice> = devices42-        override fun get(id: String): PairedDevice? = devices.firstOrNull { it.deviceId == id }43-        override fun upsert(device: PairedDevice) { devices.add(device) }44-    }45-46-    private fun server() = CodePairingServer(FakeRegistry())47-48-    private class Client {49-        val opened = CompletableFuture<Boolean>()50-        val closed = CompletableFuture<Boolean>()51-        val frames = java.util.concurrent.LinkedBlockingQueue<String>()52-        val binaryFrames = java.util.concurrent.LinkedBlockingQueue<ByteArray>()53-        lateinit var ws: WebSocket54-55-        fun connect(port: Int) {56-            ws = OkHttpClient.Builder().readTimeout(10, TimeUnit.SECONDS).build().newWebSocket(57-                Request.Builder().url("ws://127.0.0.1:$port").build(),58-                object : WebSocketListener() {59-                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }60-                    override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }61-                    override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) }62-                    override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }63-                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }64-                },65-            )66-            opened.get(5, TimeUnit.SECONDS)67-        }68-    }69-70-    /**71-     * Collects into a queue on a job that stays alive. Terminating the flow with72-     * `first {}` would trip awaitClose and shut the listener down before the73-     * test could connect to it.74-     */75-    private class Harness(val scope: CoroutineScope) {76-        val events = java.util.concurrent.LinkedBlockingQueue<PairingEvent>()77-        inline fun <reified T : PairingEvent> await(timeoutMs: Long = 10_000): T {78-            val deadline = System.currentTimeMillis() + timeoutMs79-            val seen = mutableListOf<PairingEvent>()80-            while (System.currentTimeMillis() < deadline) {81-                val e = events.poll(200, TimeUnit.MILLISECONDS) ?: continue82-                seen.add(e)83-                if (e is T) return e84-            }85-            throw AssertionError("never saw ${T::class.simpleName}; saw $seen")86-        }87-    }88-89-    private fun CoroutineScope.run(srv: CodePairingServer, windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS): Harness {90-        val h = Harness(this)91-        launch { srv.start(this@run, ByteArray(32), ByteArray(32), identity, windowMs).collect { h.events.add(it) } }92-        return h93-    }94-95-    @Test fun `a wrong token gets no reply and the socket closes`() = runBlocking {96-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)97-        val srv = server()98-        try {99-            val listening = scope.run(srv).await<PairingEvent.Listening>()100-101-            val c = Client()102-            c.connect(listening.port)103-            c.ws.send("""{"type":"PEACH_HELLO","token":"00000000"}""")104-105-            assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))106-            assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)107-        } finally {108-            srv.stop()109-        }110-    }111-112-    /**113-     * The browser's pairing socket is binary end to end (`openPairingSocket` in114-     * peach-browser's pairing-dial.ts sets `binaryType = "arraybuffer"` and115-     * sends `Uint8Array`). This listener required a TEXT hello and answered with116-     * a TEXT ack, so every correct code was silently closed on and the browser117-     * reported "invalid code" — pairing could not succeed at all. Neither half118-     * fails loudly, which is exactly why both are pinned here.119-     */120-    @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking {121-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)122-        val srv = server()123-        try {124-            val listening = scope.run(srv).await<PairingEvent.Listening>()125-            val token = PairingCode.parse(listening.code)!!.tokenHex126-127-            val c = Client()128-            c.connect(listening.port)129-            c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString())130-131-            val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS)132-            assertTrue("the phone must answer a binary hello, not close on it", ack != null)133-            assertTrue(134-                "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser",135-                c.frames.isEmpty(),136-            )137-            assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))138-        } finally {139-            srv.stop()140-        }141-    }142-143-    @Test fun `LAN adoption dispatches before device-pair auth and keeps both SAS gates`() = runBlocking {144-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)145-        val registry = FakeRegistry()146-        val srv = CodePairingServer(registry)147-        val root = ByteArray(32) { (it + 1).toByte() }148-        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")149-        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")150-        val sourceIdentity = identity.copy(151-            deviceId = "source-id",152-            deviceName = "Source phone",153-            longTermPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),154-            relayUrl = "wss://relay.example.test",155-        )156-        var rootRequested = false157-        val sourceDeps = object : AdoptionSourceDeps {158-            override val sourceIdentity = sourceIdentity159-            override suspend fun rootKeyHex(): String {160-                rootRequested = true161-                return Rendezvous.bytesToHex(root)162-            }163-            override suspend fun vaultId() = computeVaultId(root)164-            override suspend fun onConsent(request: AdoptionFrame.Request) = true165-            override suspend fun upsertPairedDevice(device: PairedDevice) {166-                registry.upsert(device)167-            }168-        }169-        val h = Harness(scope)170-        srv.confirmationSink = { it.session.confirm(true) }171-        scope.launch {172-            srv.start(173-                this,174-                sourceKeys.secretKey,175-                sourceKeys.publicKey,176-                sourceIdentity,177-                adoptionSourceDeps = sourceDeps,178-            ).collect { h.events.add(it) }179-        }180-181-        try {182-            val listening = h.await<PairingEvent.Listening>()183-            val token = PairingCode.parse(listening.code)!!.tokenHex184-            val client = Client()185-            client.connect(listening.port)186-            client.ws.send(187-                JSONObject()188-                    .put("type", "PEACH_HELLO")189-                    .put("token", token)190-                    .put("flow", "adoption")191-                    .toString()192-                    .toByteArray()193-                    .toByteString(),194-            )195-            val ack = JSONObject(String(client.binaryFrames.poll(10, TimeUnit.SECONDS)))196-            val noise = noiseNKInitiator(197-                hexToPubkey(ack.getString("ephemeralPublicKeyHex")),198-                { client.ws.send(it.toByteString()) },199-                {200-                    client.binaryFrames.poll(10, TimeUnit.SECONDS)201-                        ?: throw IllegalStateException("timed out waiting for server frame")202-                },203-            )204-            val channel = object : PairingTransport {205-                override suspend fun send(msg: ByteArray) {206-                    client.ws.send(noise.encrypt(msg).toByteString())207-                }208-                override suspend fun receive(): ByteArray {209-                    val frame = client.binaryFrames.poll(10, TimeUnit.SECONDS)210-                        ?: throw IllegalStateException("timed out waiting for encrypted frame")211-                    return noise.decrypt(frame)212-                }213-                override suspend fun close() {214-                    client.ws.close(1000, "done")215-                }216-            }217-            val receiver = requestAdoption(218-                channel,219-                object : AdoptionReceiverDeps {220-                    override val deviceName = "Browser"221-                    override val deviceType = DeviceType.EXTENSION222-                    override fun generateDeviceId() = "session-request"223-                    override suspend fun confirmSas() = true224-                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =225-                        computeVaultId(hexToPubkey(rootKeyHex))226-                    override suspend fun deriveLocalIdentity() = LocalChirpIdentity(227-                        deviceId = "receiver-id",228-                        deviceName = "Browser",229-                        deviceType = DeviceType.EXTENSION,230-                        longTermPublicKeyHex = Rendezvous.bytesToHex(receiverKeys.publicKey),231-                        signingPublicKeyHex = "ef".repeat(32),232-                    )233-                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit234-                    override fun onError(error: String) = throw AssertionError(error)235-                },236-            )237-238-            assertTrue(rootRequested)239-            assertEquals("source-id", receiver?.deviceId)240-            assertEquals("Browser", h.await<PairingEvent.Paired>().deviceName)241-            assertEquals("receiver-id", registry.devices.single().deviceId)242-            assertEquals(64, registry.devices.single().rendezvousSecretHex?.length)243-            assertEquals("wss://relay.example.test", registry.devices.single().relayUrl)244-        } finally {245-            srv.stop()246-        }247-    }248-249-    @Test fun `the pairing window expires and stops accepting`() = runBlocking {250-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)251-        val srv = server()252-        try {253-            // 300ms rather than the real five minutes; the window is a parameter254-            // precisely so this is testable at all.255-            val h = scope.run(srv, windowMs = 300)256-            val listening = h.await<PairingEvent.Listening>()257-            h.await<PairingEvent.Expired>(timeoutMs = 5_000)258-259-            var refused = false260-            runCatching { java.net.Socket("127.0.0.1", listening.port).close() }261-                .onFailure { refused = true }262-            assertTrue("the port should be closed after the window", refused)263-        } finally {264-            srv.stop()265-        }266-    }267-268-    @Test fun `the advertised address is never a tunnel interface`() {269-        // Not asserting a specific address — CI and dev machines differ — only270-        // that whatever is chosen is one a LAN peer could route back to.271-        val ip = server().localIpv4()272-        if (ip != null) {273-            assertTrue("expected a site-local address, got $ip", java.net.InetAddress.getByName(ip).isSiteLocalAddress)274-        }275-    }276-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+0 −90
@@ -1,90 +0,0 @@1-package com.peachpasswords.sync2-3-import org.junit.Assert.assertEquals4-import org.junit.Assert.assertNull5-import org.junit.Test6-7-/**8- * The phone is the only side that generates pairing codes and the browser is9- * the only side that reads them, so a drift here is invisible until a real10- * pairing fails with nothing to look at. The golden vector below was produced11- * by running the browser's own `shared/pairing-code.ts` — not derived from this12- * implementation — so it fails if either side moves.13- */14-class PairingCodeTest {15-16-    // encodePairingCode("192.168.7.115", 49142, deadbeef) in peach-browser,17-    // re-generated after both sides moved to a 4-byte token.18-    private val vectorCode = "R2M0-EWXZ-YVFA-VFQF"19-    private val vectorToken = byteArrayOf(0xde.toByte(), 0xad.toByte(), 0xbe.toByte(), 0xef.toByte())20-21-    /** The 8-byte code an older build would have shown. */22-    private val legacyCode = "R2M0-EWXZ-YVFA-VFQF-04HM-ASR"23-24-    @Test fun `encodes the browser's golden vector`() {25-        assertEquals(vectorCode, PairingCode.encode("192.168.7.115", 49142, vectorToken))26-    }27-28-    @Test fun `parses the browser's golden vector`() {29-        val parsed = PairingCode.parse(vectorCode)!!30-        assertEquals("192.168.7.115", parsed.ip)31-        assertEquals(49142, parsed.port)32-        assertEquals("deadbeef", parsed.tokenHex)33-    }34-35-    @Test fun `still decodes a code from an older eight-byte build`() {36-        // The browser kept decoding lenient on purpose so a peer that hasn't37-        // updated still pairs rather than hard-failing; match that.38-        val parsed = PairingCode.parse(legacyCode)!!39-        assertEquals("192.168.7.115", parsed.ip)40-        assertEquals(49142, parsed.port)41-        assertEquals("deadbeef01234567", parsed.tokenHex)42-    }43-44-    @Test fun `round-trips a freshly generated token`() {45-        val token = PairingCode.newToken()46-        val parsed = PairingCode.parse(PairingCode.encode("10.0.0.7", 49142, token))!!47-        assertEquals("10.0.0.7", parsed.ip)48-        assertEquals(49142, parsed.port)49-        assertEquals(token.joinToString("") { "%02x".format(it) }, parsed.tokenHex)50-    }51-52-    @Test fun `folds the ambiguous glyphs a user might read off a screen`() {53-        // I/L -> 1 and O -> 0, so the doc's own example resolves rather than54-        // failing on characters Crockford's alphabet excludes.55-        val typed = PairingCode.parse("r2mO-ewxz-yvfa-vfqf")56-        assertEquals(PairingCode.parse(vectorCode), typed)57-    }58-59-    @Test fun `rejects input that is not a pairing code`() {60-        assertNull(PairingCode.parse(""))61-        assertNull(PairingCode.parse("418207"))          // the 6-digit SAS62-        assertNull(PairingCode.parse("not-a-code!!"))    // U is not in the alphabet63-    }64-65-    @Test(expected = IllegalArgumentException::class)66-    fun `refuses to encode a zero port`() {67-        PairingCode.encode("10.0.0.1", 0, PairingCode.newToken())68-    }69-70-    @Test fun `a generated code is four groups of four`() {71-        // The length is what the user actually experiences, so pin it. Base3272-        // is 5 bits per character, so this is fixed by TOKEN_BYTES — if that73-        // grows, the code silently gains a group.74-        val code = PairingCode.encode("192.168.1.20", 49142, PairingCode.newToken())75-        assertEquals(listOf(4, 4, 4, 4), code.split("-").map { it.length })76-        assertEquals(19, code.length)77-    }78-79-    @Test(expected = IllegalArgumentException::class)80-    fun `refuses a token shorter than the canonical size`() {81-        PairingCode.encode("10.0.0.1", 49142, byteArrayOf(1, 2, 3))82-    }83-84-    @Test(expected = IllegalArgumentException::class)85-    fun `refuses a token longer than the canonical size`() {86-        // The browser throws here too. A longer token encodes fine and quietly87-        // produces a six-group code, which is the regression to prevent.88-        PairingCode.encode("10.0.0.1", 49142, ByteArray(8))89-    }90-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/RelayFramesTest.kt+0 −53
@@ -1,53 +0,0 @@1-package com.peachpasswords.sync2-3-import org.junit.Assert.assertEquals4-import org.junit.Assert.assertNull5-import org.junit.Assert.assertTrue6-import org.junit.Test7-8-/**9- * The relay frames share a channel with ordinary sync frames, so the one thing10- * that must never break is telling them apart: a sync frame misread as a relay11- * frame would be forwarded to another device, and a relay frame misread as sync12- * would be handed to the vault.13- */14-class RelayFramesTest {15-16-    @Test fun `a relay request round-trips`() {17-        val key = "ab".repeat(32)18-        val decoded = decodeRelayMessage(RelayMessage.Request(key).encodeToBytes())19-        assertEquals(RelayMessage.Request(key), decoded)20-    }21-22-    @Test fun `frames use the type discriminator the browser sync frames use`() {23-        val json = String(RelayMessage.Request("00".repeat(32)).encodeToBytes(), Charsets.UTF_8)24-        assertTrue(json, json.contains("\"type\":\"RELAY_REQUEST\""))25-        assertTrue(json, json.contains("\"targetDeviceId\":"))26-    }27-28-    @Test fun `sync traffic is not mistaken for a relay frame`() {29-        val hello = SyncMessage.Hello("v", "d", 1, 2).encodeToBytes()30-        assertNull(decodeRelayMessage(hello))31-    }32-33-    @Test fun `junk and empty frames are not relay frames`() {34-        assertNull(decodeRelayMessage(ByteArray(0)))35-        assertNull(decodeRelayMessage(byteArrayOf(0x01, 0x02, 0x03)))36-        assertNull(decodeRelayMessage("not json".toByteArray()))37-    }38-39-    @Test fun `a payload survives forwarding unchanged`() {40-        val opaque = java.util.Base64.getEncoder().encodeToString(byteArrayOf(0, 1, 2, -1, -2))41-        val decoded = decodeRelayMessage(RelayMessage.Payload(opaque).encodeToBytes())42-        assertEquals(opaque, (decoded as RelayMessage.Payload).payload)43-    }44-45-    @Test fun `the parameterless responses decode by type alone`() {46-        listOf(47-            RelayMessage.Accept,48-            RelayMessage.Unavailable,49-            RelayMessage.Disabled,50-            RelayMessage.Close,51-        ).forEach { assertEquals(it, decodeRelayMessage(it.encodeToBytes())) }52-    }53-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/RelayHubTest.kt+0 −162
@@ -1,162 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.runBlocking4-import org.junit.Assert.assertArrayEquals5-import org.junit.Assert.assertEquals6-import org.junit.Assert.assertTrue7-import org.junit.Test8-9-/**10- * The relay's whole job is to move bytes it must not understand between two11- * peers that cannot reach each other. The failures worth pinning are the quiet12- * ones: a rejection reason that collapses into the wrong one (the requester then13- * retries the same dead relay forever), and a payload that gets re-encoded on14- * the way through (the end-to-end session breaks with nothing in the logs).15- */16-class RelayHubTest {17-18-    private val keyA = "aa".repeat(32)19-    private val keyB = "bb".repeat(32)20-21-    private class Peer {22-        val sent = mutableListOf<ByteArray>()23-        val messages: List<RelayMessage?> get() = sent.map { decodeRelayMessage(it) }24-        val sink: suspend (ByteArray) -> Unit = { sent.add(it) }25-    }26-27-    private fun hub(enabled: Boolean = true) = RelayHub(object : RelayPolicy {28-        override fun isRelayEnabled() = enabled29-    })30-31-    @Test fun `relaying turned off is reported as disabled, not unavailable`() = runBlocking {32-        val hub = hub(enabled = false)33-        val a = Peer()34-        val b = Peer()35-        val linkA = hub.register(keyA, a.sink)36-        hub.register(keyB, b.sink)37-38-        hub.onRequest(linkA, keyB)39-40-        assertEquals(listOf(RelayMessage.Disabled), a.messages)41-        assertTrue("the target must not be told about a refused request", b.sent.isEmpty())42-    }43-44-    @Test fun `an absent target is unavailable`() = runBlocking {45-        val hub = hub()46-        val a = Peer()47-        val linkA = hub.register(keyA, a.sink)48-49-        hub.onRequest(linkA, keyB)50-51-        assertEquals(listOf(RelayMessage.Unavailable), a.messages)52-    }53-54-    @Test fun `a target busy with its own sync is unavailable`() = runBlocking {55-        val hub = hub()56-        val a = Peer()57-        val b = Peer()58-        val linkA = hub.register(keyA, a.sink)59-        val linkB = hub.register(keyB, b.sink)60-        hub.beginSync(linkB)61-62-        hub.onRequest(linkA, keyB)63-        assertEquals(listOf(RelayMessage.Unavailable), a.messages)64-65-        // …and becomes available again once that sync finishes.66-        hub.endSync(linkB)67-        hub.onRequest(linkA, keyB)68-        assertEquals(RelayMessage.Accept, a.messages.last())69-    }70-71-    @Test fun `a channel opens with the target told first`() = runBlocking {72-        val hub = hub()73-        val a = Peer()74-        val b = Peer()75-        val linkA = hub.register(keyA, a.sink)76-        hub.register(keyB, b.sink)77-78-        hub.onRequest(linkA, keyB)79-80-        assertEquals(listOf(RelayMessage.Open(keyA)), b.messages)81-        assertEquals(listOf(RelayMessage.Accept), a.messages)82-    }83-84-    @Test fun `frames cross verbatim in both directions`() = runBlocking {85-        val hub = hub()86-        val a = Peer()87-        val b = Peer()88-        val linkA = hub.register(keyA, a.sink)89-        val linkB = hub.register(keyB, b.sink)90-        hub.onRequest(linkA, keyB)91-92-        // Not a RelayMessage encoding — whatever arrived must land unaltered,93-        // which is exactly what the relay promises about the inner session.94-        val opaque = "{\"type\":\"RELAY_FRAME\",\"payload\":\"ZmFrZQ==\"}".toByteArray()95-        assertTrue(hub.forward(linkA, opaque))96-        assertArrayEqualsLast(opaque, b.sent)97-98-        val back = "{\"type\":\"RELAY_FRAME\",\"payload\":\"b3RoZXI=\"}".toByteArray()99-        assertTrue(hub.forward(linkB, back))100-        assertArrayEqualsLast(back, a.sent)101-    }102-103-    @Test fun `forwarding without a channel fails rather than guessing a destination`() = runBlocking {104-        val hub = hub()105-        val a = Peer()106-        hub.register(keyB, Peer().sink)107-        val linkA = hub.register(keyA, a.sink)108-109-        assertEquals(false, hub.forward(linkA, "x".toByteArray()))110-    }111-112-    @Test fun `a dropped connection closes the other end of the channel`() = runBlocking {113-        val hub = hub()114-        val a = Peer()115-        val b = Peer()116-        val linkA = hub.register(keyA, a.sink)117-        val linkB = hub.register(keyB, b.sink)118-        hub.onRequest(linkA, keyB)119-120-        hub.unregister(linkA)121-122-        assertEquals(RelayMessage.Close, b.messages.last())123-        // B is free to serve a new channel afterwards.124-        val c = Peer()125-        val linkC = hub.register("cc".repeat(32), c.sink)126-        hub.onRequest(linkC, keyB)127-        assertEquals(RelayMessage.Accept, c.messages.last())128-    }129-130-    @Test fun `a link cannot relay to itself`() = runBlocking {131-        val hub = hub()132-        val a = Peer()133-        val linkA = hub.register(keyA, a.sink)134-135-        hub.onRequest(linkA, keyA)136-137-        assertEquals(listOf(RelayMessage.Unavailable), a.messages)138-    }139-140-    @Test fun `two laptop pairs can be relayed at once`() = runBlocking {141-        val hub = hub()142-        val peers = List(4) { Peer() }143-        val keys = List(4) { "0$it".repeat(32) }144-        val links = keys.mapIndexed { i, k -> hub.register(k, peers[i].sink) }145-146-        hub.onRequest(links[0], keys[1])147-        hub.onRequest(links[2], keys[3])148-149-        assertEquals(RelayMessage.Accept, peers[0].messages.last())150-        assertEquals(RelayMessage.Accept, peers[2].messages.last())151-        assertTrue(hub.forward(links[0], "one".toByteArray()))152-        assertTrue(hub.forward(links[2], "two".toByteArray()))153-        assertArrayEqualsLast("one".toByteArray(), peers[1].sent)154-        assertArrayEqualsLast("two".toByteArray(), peers[3].sent)155-    }156-157-    private fun assertArrayEqualsLast(expected: ByteArray, sent: List<ByteArray>) {158-        val last = sent.lastOrNull()159-        assertTrue("nothing was forwarded", last != null)160-        assertArrayEquals(expected, last)161-    }162-}

view file at the parent

app/src/test/java/com/peachpasswords/sync/SyncReconnectListenerRelayTest.kt+0 −310
@@ -1,310 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.CoroutineScope4-import kotlinx.coroutines.Dispatchers5-import kotlinx.coroutines.SupervisorJob6-import kotlinx.coroutines.cancel7-import kotlinx.coroutines.runBlocking8-import okhttp3.OkHttpClient9-import okhttp3.Request10-import okhttp3.Response11-import okhttp3.WebSocket12-import okhttp3.WebSocketListener13-import okio.ByteString14-import okio.ByteString.Companion.toByteString15-import org.bouncycastle.crypto.params.X25519PrivateKeyParameters16-import org.junit.Assert.assertArrayEquals17-import org.junit.Assert.assertEquals18-import org.junit.Assert.assertTrue19-import org.junit.Test20-import java.security.SecureRandom21-import java.util.concurrent.CompletableFuture22-import java.util.concurrent.LinkedBlockingQueue23-import java.util.concurrent.TimeUnit24-25-/**26- * The dispatch itself, over a real socket and a real Noise session: two paired27- * "laptops" connect, one asks to be relayed to the other, and a frame crosses.28- *29- * This is the half the design doc calls the harder one, and the part unit-level30- * hub tests cannot reach — telling "sync traffic for my own vault" apart from31- * "forward this blindly" happens on a decrypted frame inside the listener, and32- * getting it wrong either hands another device's bytes to the vault or hands the33- * vault's bytes to another device.34- */35-class SyncReconnectListenerRelayTest {36-37-    private class FakeRegistry(devices: List<PairedDevice>) : PairedDeviceRegistry {38-        private val all = devices.toMutableList()39-        override fun getAll(): List<PairedDevice> = all40-        override fun get(id: String): PairedDevice? = all.firstOrNull { it.deviceId == id }41-        override fun upsert(device: PairedDevice) {42-            all.removeAll { it.deviceId == device.deviceId }43-            all.add(device)44-        }45-    }46-47-    private fun identity(name: String, keyHex: String) = LocalChirpIdentity(48-        deviceId = name,49-        deviceName = name,50-        deviceType = DeviceType.EXTENSION,51-        longTermPublicKeyHex = keyHex,52-        signingPublicKeyHex = "11".repeat(32),53-    )54-55-    private fun keys(): NoiseKKKeyPair {56-        val privateKey = X25519PrivateKeyParameters(SecureRandom())57-        return NoiseKKKeyPair(privateKey.generatePublicKey().encoded, privateKey.encoded)58-    }59-60-    /** A browser-side peer: selects paired state, completes KK, presents identity. */61-    private class Laptop(62-        private val identity: LocalChirpIdentity,63-        private val keys: NoiseKKKeyPair,64-        private val rendezvousSecretHex: String,65-    ) {66-        val inbox = LinkedBlockingQueue<ByteArray>()67-        private val opened = CompletableFuture<Boolean>()68-        @Volatile var trouble: String? = null69-        lateinit var ws: WebSocket70-        lateinit var session: NoiseNKSession71-72-        suspend fun connect(port: Int, serverPublicKey: ByteArray) {73-            ws = OkHttpClient().newWebSocket(74-                Request.Builder().url("ws://127.0.0.1:$port").build(),75-                object : WebSocketListener() {76-                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }77-                    override fun onMessage(webSocket: WebSocket, bytes: ByteString) { inbox.add(bytes.toByteArray()) }78-                    override fun onMessage(webSocket: WebSocket, text: String) { trouble = "text frame: $text" }79-                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { trouble = "closed $code $reason" }80-                    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { trouble = "failed: $t" }81-                },82-            )83-            opened.get(10, TimeUnit.SECONDS)84-            val rendezvousId = Rendezvous.dialingIds(85-                Rendezvous.secretFromHex(rendezvousSecretHex),86-                System.currentTimeMillis() / 1000L,87-            ).first()88-            ws.send(89-                """{"type":"PEACH_RECONNECT","rendezvous_id":"$rendezvousId"}"""90-                    .toByteArray()91-                    .toByteString(),92-            )93-            session = noiseKKInitiator(94-                keys.secretKey,95-                keys.publicKey,96-                serverPublicKey,97-                send = { ws.send(it.toByteString()) },98-                receive = { take() },99-            )100-            ws.send(session.encrypt(SyncPadding.pad(encodePairIdentityFrame(identity))).toByteString())101-            SyncPadding.unpad(session.decrypt(take())) // the phone's own PAIR_IDENTITY102-        }103-104-        fun send(message: RelayMessage) = ws.send(session.encrypt(SyncPadding.pad(message.encodeToBytes())).toByteString())105-106-        /** Sends bytes the relay has no business parsing. */107-        fun sendRaw(plaintext: ByteArray) = ws.send(session.encrypt(SyncPadding.pad(plaintext)).toByteString())108-109-        fun next(): RelayMessage? = decodeRelayMessage(SyncPadding.unpad(session.decrypt(take())))110-111-        fun nextRaw(): ByteArray = SyncPadding.unpad(session.decrypt(take()))112-113-        private fun take(): ByteArray =114-            inbox.poll(10, TimeUnit.SECONDS) ?: throw AssertionError("no frame from the phone (${trouble ?: "socket still open"})")115-    }116-117-    @Test fun `one laptop is relayed to another and frames cross untouched`() = runBlocking {118-        val priv = X25519PrivateKeyParameters(SecureRandom())119-        val serverPub = priv.generatePublicKey().encoded120-        val keysA = keys()121-        val keysB = keys()122-        val keyA = pubkeyToHex(keysA.publicKey)123-        val keyB = pubkeyToHex(keysB.publicKey)124-        val secretA = "01".repeat(32)125-        val secretB = "02".repeat(32)126-        val registry = FakeRegistry(127-            listOf(128-                PairedDevice(129-                    deviceId = "A",130-                    longTermPublicKeyHex = keyA,131-                    pairedAt = 0,132-                    rendezvousSecretHex = secretA,133-                ),134-                PairedDevice(135-                    deviceId = "B",136-                    longTermPublicKeyHex = keyB,137-                    pairedAt = 0,138-                    rendezvousSecretHex = secretB,139-                ),140-            ),141-        )142-        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })143-        val listener = SyncReconnectListener(registry, hub)144-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)145-146-        try {147-            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }148-            val port = awaitBound(listener)149-150-            val a = Laptop(identity("A", keyA), keysA, secretA)151-            val b = Laptop(identity("B", keyB), keysB, secretB)152-            a.connect(port, serverPub)153-            b.connect(port, serverPub)154-155-            a.send(RelayMessage.Request(keyB))156-            assertEquals(RelayMessage.Open(keyA), b.next())157-            assertEquals(RelayMessage.Accept, a.next())158-159-            // Deliberately not a well-formed RelayMessage body: the relay must160-            // move the bytes, not understand them.161-            val opaque = """{"type":"RELAY_FRAME","payload":"c2VjcmV0"}""".toByteArray()162-            a.sendRaw(opaque)163-            assertArrayEquals(opaque, b.nextRaw())164-165-            val back = """{"type":"RELAY_FRAME","payload":"cmVwbHk="}""".toByteArray()166-            b.sendRaw(back)167-            assertArrayEquals(back, a.nextRaw())168-        } finally {169-            listener.stop()170-            scope.cancel()171-        }172-    }173-174-    @Test fun `a request for a laptop that is not connected is answered as unavailable`() = runBlocking {175-        val priv = X25519PrivateKeyParameters(SecureRandom())176-        val serverPub = priv.generatePublicKey().encoded177-        val keysA = keys()178-        val keyA = pubkeyToHex(keysA.publicKey)179-        val secretA = "03".repeat(32)180-        val registry = FakeRegistry(181-            listOf(182-                PairedDevice(183-                    deviceId = "A",184-                    longTermPublicKeyHex = keyA,185-                    pairedAt = 0,186-                    rendezvousSecretHex = secretA,187-                ),188-            ),189-        )190-        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true })191-        val listener = SyncReconnectListener(registry, hub)192-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)193-194-        try {195-            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }196-            val port = awaitBound(listener)197-198-            val a = Laptop(identity("A", keyA), keysA, secretA)199-            a.connect(port, serverPub)200-            a.send(RelayMessage.Request("bb".repeat(32)))201-202-            assertEquals(RelayMessage.Unavailable, a.next())203-        } finally {204-            listener.stop()205-            scope.cancel()206-        }207-    }208-209-    @Test fun `relaying switched off is answered as disabled so the laptop tries another relay`() = runBlocking {210-        val priv = X25519PrivateKeyParameters(SecureRandom())211-        val serverPub = priv.generatePublicKey().encoded212-        val keysA = keys()213-        val keysB = keys()214-        val keyA = pubkeyToHex(keysA.publicKey)215-        val keyB = pubkeyToHex(keysB.publicKey)216-        val secretA = "04".repeat(32)217-        val secretB = "05".repeat(32)218-        val registry = FakeRegistry(219-            listOf(220-                PairedDevice(221-                    deviceId = "A",222-                    longTermPublicKeyHex = keyA,223-                    pairedAt = 0,224-                    rendezvousSecretHex = secretA,225-                ),226-                PairedDevice(227-                    deviceId = "B",228-                    longTermPublicKeyHex = keyB,229-                    pairedAt = 0,230-                    rendezvousSecretHex = secretB,231-                ),232-            ),233-        )234-        val hub = RelayHub(object : RelayPolicy { override fun isRelayEnabled() = false })235-        val listener = SyncReconnectListener(registry, hub)236-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)237-238-        try {239-            listener.start(scope, priv.encoded, serverPub, identity("phone", pubkeyToHex(serverPub)), port = 0) { null }240-            val port = awaitBound(listener)241-242-            val a = Laptop(identity("A", keyA), keysA, secretA)243-            val b = Laptop(identity("B", keyB), keysB, secretB)244-            a.connect(port, serverPub)245-            b.connect(port, serverPub)246-247-            a.send(RelayMessage.Request(keyB))248-            assertEquals(RelayMessage.Disabled, a.next())249-        } finally {250-            listener.stop()251-            scope.cancel()252-        }253-    }254-255-    @Test fun `an unknown reconnect selector is dropped with no NK fallback`() = runBlocking {256-        val phonePrivate = X25519PrivateKeyParameters(SecureRandom())257-        val phonePublic = phonePrivate.generatePublicKey().encoded258-        val listener = SyncReconnectListener(259-            FakeRegistry(emptyList()),260-            RelayHub(object : RelayPolicy { override fun isRelayEnabled() = true }),261-        )262-        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)263-        val opened = CompletableFuture<Boolean>()264-        val closed = CompletableFuture<Boolean>()265-        val received = LinkedBlockingQueue<ByteArray>()266-        try {267-            listener.start(268-                scope,269-                phonePrivate.encoded,270-                phonePublic,271-                identity("phone", pubkeyToHex(phonePublic)),272-                port = 0,273-            ) { null }274-            val socket = OkHttpClient().newWebSocket(275-                Request.Builder().url("ws://127.0.0.1:${awaitBound(listener)}").build(),276-                object : WebSocketListener() {277-                    override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }278-                    override fun onMessage(webSocket: WebSocket, bytes: ByteString) {279-                        received.add(bytes.toByteArray())280-                    }281-                    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {282-                        closed.complete(true)283-                    }284-                    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {285-                        closed.complete(true)286-                    }287-                },288-            )289-            opened.get(10, TimeUnit.SECONDS)290-            socket.send(291-                """{"type":"PEACH_RECONNECT","rendezvous_id":"${"Z".repeat(43)}"}"""292-                    .toByteArray()293-                    .toByteString(),294-            )295-296-            assertTrue("unknown local state must close", closed.get(5, TimeUnit.SECONDS))297-            assertTrue("listener must send no KK or NK response", received.isEmpty())298-        } finally {299-            listener.stop()300-            scope.cancel()301-        }302-    }303-304-    /** start() binds on an IO coroutine, so the port is not up on return. */305-    private fun awaitBound(listener: SyncReconnectListener): Int {306-        val deadline = System.currentTimeMillis() + 10_000307-        while (listener.boundPort == null && System.currentTimeMillis() < deadline) Thread.sleep(20)308-        return listener.boundPort ?: throw AssertionError("listener never bound")309-    }310-}

view file at the parent