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
↓ peach-android-944cf615.tar.gzatlas at this save
files changed31 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 −310app/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 {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 }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 }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 returnapp/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-}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? {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-}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+}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-}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-}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+}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,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 theapp/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"))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")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-}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-}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)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 * 1000app/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-}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 -> {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() {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 }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 /**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 }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-}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-}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-}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-}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-}