a3694f583c46

feat: add relay-first vault adoption source (v2.3.78)

time
2026-07-26 05:08 UTC · 3 months ago
machine
koh2
parent
b7c31dd4fa00
11 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+16 −1app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+80 −1app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+58 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+183 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+62 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+125 −13app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+107 −0app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+29 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+177 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23770020-        versionName "2.3.77"19+        versionCode 23780020+        versionName "2.3.78"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpAdopt.kt+16 −1
@@ -71,9 +71,15 @@7171         @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,7272         @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,7373         @SerialName("ws_port") val wsPort: Int? = null,74+        @SerialName("relay_url") val relayUrl: String? = null,7475     )7576 }7677 78+data class AdoptionPairState(79+    val rendezvousSecretHex: String,80+    val relayUrl: String? = null,81+)82+7783 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */7884 interface AdoptionReceiverDeps {7985     val deviceName: String@@ -87,6 +93,7 @@8793     suspend fun deriveLocalIdentity(): LocalChirpIdentity8894     suspend fun upsertPairedDevice(device: PairedDevice)8995     suspend fun applyLicense(license: String) {}96+    val pairState: AdoptionPairState? get() = null9097     fun onError(error: String)9198 }9299 @@ -105,6 +112,7 @@105112      */106113     suspend fun onConsent(request: AdoptionFrame.Request): Boolean107114     suspend fun upsertPairedDevice(device: PairedDevice)115+    val pairState: AdoptionPairState? get() = null108116 }109117 110118 private val Hex64 = Regex("^[0-9a-f]{64}\$")@@ -121,7 +129,9 @@121129         validateRootKeyHex(frame.rootKeyHex) &&122130         Hex64.matches(frame.vaultId) &&123131         isDeviceType(frame.source.deviceType) &&124-        Hex64.matches(frame.source.longTermPublicKeyHex)132+        Hex64.matches(frame.source.longTermPublicKeyHex) &&133+        (frame.source.relayUrl == null ||134+            runCatching { Rendezvous.normalizeRelayUrl(frame.source.relayUrl) }.isSuccess)125135 126136 fun isAdoptConfirm(frame: AdoptionFrame?): Boolean =127137     frame is AdoptionFrame.Confirm@@ -201,6 +211,8 @@201211         signingPublicKeyHex = grant.source.signingPublicKeyHex,202212         trustSource = "direct",203213         wsPort = grant.source.wsPort,214+        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,215+        relayUrl = deps.pairState?.relayUrl ?: grant.source.relayUrl,204216         pairedAt = nowMs(),205217     )206218     deps.upsertPairedDevice(sourceDevice)@@ -239,6 +251,7 @@239251             longTermPublicKeyHex = deps.sourceIdentity.longTermPublicKeyHex,240252             signingPublicKeyHex = deps.sourceIdentity.signingPublicKeyHex,241253             wsPort = deps.sourceIdentity.wsPort,254+            relayUrl = deps.pairState?.relayUrl,242255         ),243256     ))244257 @@ -253,6 +266,8 @@253266         longTermPublicKeyHex = identity.longTermPublicKeyHex,254267         signingPublicKeyHex = identity.signingPublicKeyHex,255268         trustSource = "direct",269+        rendezvousSecretHex = deps.pairState?.rendezvousSecretHex,270+        relayUrl = deps.pairState?.relayUrl,256271         pairedAt = nowMs(),257272     )258273     deps.upsertPairedDevice(device)

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+1 −1
@@ -239,7 +239,7 @@239239     }240240 }241241 242-private object OkHttpRelaySocket {242+internal object OkHttpRelaySocket {243243     private val client = OkHttpClient.Builder()244244         .readTimeout(0, TimeUnit.MILLISECONDS)245245         .build()

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+80 −1
@@ -98,6 +98,7 @@9898         ourStaticPublicKey: ByteArray,9999         localIdentity: LocalChirpIdentity,100100         windowMs: Long = PAIRING_WINDOW_MS,101+        adoptionSourceDeps: AdoptionSourceDeps? = null,101102     ): Flow<PairingEvent> = callbackFlow {102103         val ip = localIpv4()103104         if (ip == null) {@@ -120,13 +121,21 @@120121             delay(windowMs)121122             trySend(PairingEvent.Expired)122123             stop()124+            this@callbackFlow.close()123125         }124126 125127         val acceptJob = scope.launch(Dispatchers.IO) {126128             while (isActive && !socket.isClosed) {127129                 val client = runCatching { socket.accept() }.getOrNull() ?: break128130                 launch {129-                    val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)131+                    val event = handleClient(132+                        client,133+                        tokenHex,134+                        ourStaticPrivateKey,135+                        ourStaticPublicKey,136+                        localIdentity,137+                        adoptionSourceDeps,138+                    )130139                     trySend(event)131140                     // One code, one device. A rejected or failed attempt leaves132141                     // the window open so the user can retry without generating@@ -134,6 +143,7 @@134143                     if (event is PairingEvent.Paired) {135144                         expiryJob.cancel()136145                         stop()146+                        this@callbackFlow.close()137147                     }138148                 }139149             }@@ -157,6 +167,7 @@157167         ourStaticPrivateKey: ByteArray,158168         ourStaticPublicKey: ByteArray,159169         localIdentity: LocalChirpIdentity,170+        adoptionSourceDeps: AdoptionSourceDeps?,160171     ): PairingEvent = withContext(Dispatchers.IO) {161172         val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())162173         try {@@ -185,6 +196,10 @@185196             if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {186197                 return@withContext rejectSilently(ws, "bad_token")187198             }199+            val flow = helloJson.optString("flow").ifBlank { null }200+            if (flow != null && flow != "adoption") {201+                return@withContext rejectSilently(ws, "unsupported_flow")202+            }188203 189204             // Binary, for the same reason: the browser reads every frame as190205             // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a@@ -205,6 +220,17 @@205220                 { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },206221             )207222 223+            if (flow == "adoption") {224+                val deps = adoptionSourceDeps225+                    ?: return@withContext rejectSilently(ws, "adoption_unavailable")226+                return@withContext handleAdoption(227+                    ws,228+                    session,229+                    localIdentity,230+                    deps,231+                )232+            }233+208234             // E1: the initiator proves it holds the private half of the key it209235             // will advertise. Bind that proof to this NK transcript before any210236             // identity frame is accepted or SAS consent is requested.@@ -259,6 +285,59 @@259285             PairingEvent.Rejected(e.message ?: "pairing_failed")260286         } finally {261287             runCatching { client.close() }288+        }289+    }290+291+    private suspend fun handleAdoption(292+        ws: WebSocketConnection,293+        session: NoiseNKSession,294+        localIdentity: LocalChirpIdentity,295+        sourceDeps: AdoptionSourceDeps,296+    ): PairingEvent {297+        val channel = object : PairingTransport {298+            override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))299+            override suspend fun receive(): ByteArray = session.decrypt(300+                (ws.receive() as? WsMessage.Binary)?.bytes301+                    ?: throw IllegalStateException("peer closed"),302+            )303+            override suspend fun close() = ws.closeSilently()304+        }305+        val request = receiveFrame(channel)306+        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }307+        request as AdoptionFrame.Request308+309+        val pending = PairingSession()310+        val name = request.deviceName.ifBlank { "New device" }311+        confirmationSink?.invoke(312+            PairingEvent.AwaitingConfirmation(313+                SASDerivation.derive(session.handshakeHash),314+                name,315+                pending,316+            ),317+        )318+        if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")319+320+        val pairState = AdoptionPairState(321+            rendezvousSecretHex = Rendezvous.bytesToHex(322+                Rendezvous.deriveSecret(session.handshakeHash),323+            ),324+            relayUrl = localIdentity.relayUrl,325+        )326+        val boundDeps = object : AdoptionSourceDeps {327+            override val sourceIdentity = localIdentity328+            override val pairState = pairState329+            override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()330+            override suspend fun vaultId() = sourceDeps.vaultId()331+            override suspend fun license() = sourceDeps.license()332+            override suspend fun onConsent(request: AdoptionFrame.Request) = true333+            override suspend fun upsertPairedDevice(device: PairedDevice) {334+                sourceDeps.upsertPairedDevice(device)335+            }336+        }337+        return if (grantAdoption(channel, request, boundDeps) != null) {338+            PairingEvent.Paired(name)339+        } else {340+            PairingEvent.Rejected("adoption_rejected")262341         }263342     }264343 

view file

app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+58 −0
@@ -0,0 +1,58 @@1+package com.peachpasswords.sync2+3+import java.util.Base644+import kotlinx.serialization.Serializable5+import kotlinx.serialization.encodeToString6+import kotlinx.serialization.json.Json7+8+private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"9+private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")10+11+data class PairingInvitation(12+    val relayUrl: String,13+    val token: String,14+)15+16+@Serializable17+private data class PairingInvitationWire(18+    val relay: String,19+    val token: String,20+)21+22+/**23+ * QR/manual first-pair payload. Only reachability is present before Noise:24+ * a credential-free wss relay and a fresh 128-bit token. Never add identity or25+ * vault fields here.26+ */27+fun createPairingInvitation(28+    relayUrl: String,29+    token: String = Rendezvous.newPairingToken(),30+): String {31+    require(PairingTokenPattern.matches(token)) {32+        "pairing invitation token must be 128-bit base64url"33+    }34+    val wire = PairingInvitationWire(Rendezvous.normalizeRelayUrl(relayUrl), token)35+    val encoded = Base64.getUrlEncoder().withoutPadding()36+        .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))37+    return PAIRING_INVITATION_PREFIX + encoded38+}39+40+fun parsePairingInvitation(input: String): PairingInvitation? {41+    val value = input.trim()42+    if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null43+    return runCatching {44+        val decoded = Base64.getUrlDecoder().decode(45+            value.removePrefix(PAIRING_INVITATION_PREFIX),46+        )47+        val wire = Json.decodeFromString<PairingInvitationWire>(48+            String(decoded, Charsets.UTF_8),49+        )50+        require(PairingTokenPattern.matches(wire.token))51+        PairingInvitation(52+            relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),53+            token = wire.token,54+        )55+    }.getOrNull()56+}57+58+fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+183 −0
@@ -0,0 +1,183 @@1+package com.peachpasswords.sync2+3+import java.security.MessageDigest4+import javax.inject.Inject5+import javax.inject.Singleton6+import kotlinx.coroutines.CoroutineScope7+import kotlinx.coroutines.Dispatchers8+import kotlinx.coroutines.Job9+import kotlinx.coroutines.channels.awaitClose10+import kotlinx.coroutines.flow.Flow11+import kotlinx.coroutines.flow.callbackFlow12+import kotlinx.coroutines.launch13+import kotlinx.coroutines.withTimeout14+import org.json.JSONObject15+16+sealed interface RelayAdoptionEvent {17+    /** The source is already occupying the non-buffering room; it is safe to show this. */18+    data class InvitationReady(val invitation: String) : RelayAdoptionEvent19+    data class AwaitingConfirmation(20+        val sas: String,21+        val deviceName: String,22+        val session: PairingSession,23+    ) : RelayAdoptionEvent24+    data class Adopted(val deviceName: String) : RelayAdoptionEvent25+    data object Expired : RelayAdoptionEvent26+    data class Rejected(val reason: String) : RelayAdoptionEvent27+}28+29+@Singleton30+class RelayAdoptionHost @Inject constructor(31+    private val relaySettings: RelaySettingsStore,32+) {33+    private var job: Job? = null34+    private var socket: RelayBinarySocket? = null35+36+    fun start(37+        scope: CoroutineScope,38+        ourStaticPrivateKey: ByteArray,39+        ourStaticPublicKey: ByteArray,40+        localIdentity: LocalChirpIdentity,41+        sourceDeps: AdoptionSourceDeps,42+        windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,43+    ): Flow<RelayAdoptionEvent> = callbackFlow {44+        val relayUrl = relaySettings.relayUrl()45+        val token = Rendezvous.newPairingToken()46+        val invitation = createPairingInvitation(relayUrl, token)47+48+        job = scope.launch(Dispatchers.IO) {49+            try {50+                val connected = OkHttpRelaySocket.connect(51+                    Rendezvous.buildUrl(relayUrl, token),52+                )53+                socket = connected54+                // The relay buffers nothing, so the invitation is not surfaced55+                // until the source WebSocket is open and waiting in the room.56+                trySend(RelayAdoptionEvent.InvitationReady(invitation))57+58+                val adopted = withTimeout(windowMs) {59+                    runRelayAdoptionSourceSession(60+                        socket = connected,61+                        expectedToken = token,62+                        relayUrl = relayUrl,63+                        ourStaticPrivateKey = ourStaticPrivateKey,64+                        ourStaticPublicKey = ourStaticPublicKey,65+                        localIdentity = localIdentity,66+                        sourceDeps = sourceDeps,67+                        confirm = { sas, request ->68+                            val pending = PairingSession()69+                            trySend(70+                                RelayAdoptionEvent.AwaitingConfirmation(71+                                    sas = sas,72+                                    deviceName = request.deviceName.ifBlank { "New device" },73+                                    session = pending,74+                                ),75+                            )76+                            pending.await()77+                        },78+                    )79+                }80+                if (adopted == null) {81+                    trySend(RelayAdoptionEvent.Rejected("adoption_rejected"))82+                } else {83+                    trySend(84+                        RelayAdoptionEvent.Adopted(85+                            adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device",86+                        ),87+                    )88+                }89+            } catch (error: kotlinx.coroutines.TimeoutCancellationException) {90+                trySend(RelayAdoptionEvent.Expired)91+            } catch (error: Throwable) {92+                trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))93+            } finally {94+                socket?.close()95+                socket = null96+                close()97+            }98+        }99+100+        awaitClose { stop() }101+    }102+103+    fun stop() {104+        job?.cancel()105+        job = null106+        socket?.close()107+        socket = null108+    }109+}110+111+/**112+ * Source half of first-contact adoption through an opaque relay splice.113+ *114+ * The relay token selects only a room. Authentication and confidentiality115+ * begin with Noise NK; the root grant remains behind both SAS decisions in116+ * [grantAdoption].117+ */118+internal suspend fun runRelayAdoptionSourceSession(119+    socket: RelayBinarySocket,120+    expectedToken: String,121+    relayUrl: String,122+    ourStaticPrivateKey: ByteArray,123+    ourStaticPublicKey: ByteArray,124+    localIdentity: LocalChirpIdentity,125+    sourceDeps: AdoptionSourceDeps,126+    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,127+): PairedDevice? {128+    val hello = JSONObject(String(socket.receive(), Charsets.UTF_8))129+    require(hello.optString("type") == "PEACH_HELLO") { "expected PEACH_HELLO" }130+    require(131+        MessageDigest.isEqual(132+            expectedToken.toByteArray(Charsets.US_ASCII),133+            hello.optString("token").toByteArray(Charsets.US_ASCII),134+        ),135+    ) { "pairing token mismatch" }136+137+    socket.send(138+        JSONObject()139+            .put("type", "PEACH_HELLO_ACK")140+            .put(141+                "ephemeralPublicKeyHex",142+                ourStaticPublicKey.joinToString("") { "%02x".format(it) },143+            )144+            .toString()145+            .toByteArray(Charsets.UTF_8),146+    )147+148+    val noise = noiseNKResponder(149+        ourStaticPrivateKey,150+        ourStaticPublicKey,151+        socket::send,152+        socket::receive,153+    )154+    val channel = object : PairingTransport {155+        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))156+        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())157+        override suspend fun close() = socket.close()158+    }159+160+    val request = receiveFrame(channel)161+    require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }162+    request as AdoptionFrame.Request163+    if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null164+165+    val pairState = AdoptionPairState(166+        rendezvousSecretHex = Rendezvous.bytesToHex(167+            Rendezvous.deriveSecret(noise.handshakeHash),168+        ),169+        relayUrl = Rendezvous.normalizeRelayUrl(relayUrl),170+    )171+    val boundDeps = object : AdoptionSourceDeps {172+        override val sourceIdentity: LocalChirpIdentity = localIdentity173+        override val pairState: AdoptionPairState = pairState174+        override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()175+        override suspend fun vaultId(): String = sourceDeps.vaultId()176+        override suspend fun license(): String? = sourceDeps.license()177+        override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true178+        override suspend fun upsertPairedDevice(device: PairedDevice) {179+            sourceDeps.upsertPairedDevice(device)180+        }181+    }182+    return grantAdoption(channel, request, boundDeps)183+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+62 −2
@@ -15,9 +15,11 @@1515 import androidx.compose.foundation.layout.height1616 import androidx.compose.foundation.layout.padding1717 import androidx.compose.foundation.layout.systemBarsPadding18+import androidx.compose.foundation.Canvas1819 import androidx.compose.material3.Text1920 import androidx.compose.runtime.Composable2021 import androidx.compose.runtime.LaunchedEffect22+import androidx.compose.runtime.remember2123 import androidx.compose.runtime.getValue2224 import androidx.compose.ui.platform.LocalContext2325 import androidx.compose.ui.Alignment@@ -26,6 +28,10 @@2628 import androidx.compose.ui.text.style.TextAlign2729 import androidx.compose.ui.unit.dp2830 import androidx.compose.ui.unit.sp31+import androidx.compose.ui.geometry.Offset32+import androidx.compose.ui.geometry.Size33+import androidx.compose.ui.platform.LocalClipboardManager34+import androidx.compose.ui.text.AnnotatedString2935 import androidx.hilt.navigation.compose.hiltViewModel3036 import androidx.lifecycle.compose.collectAsStateWithLifecycle3137 import com.peachpasswords.ui.components.BirdAnimation@@ -35,6 +41,8 @@3541 import com.peachpasswords.ui.theme.PeachType3642 import com.peachpasswords.ui.theme.ShellBackground3743 import com.peachpasswords.ui.theme.peachColors44+import com.google.zxing.BarcodeFormat45+import com.google.zxing.qrcode.QRCodeWriter3846 3947 /**4048  * Pair a device by code. The phone shows the code; the browser types it in and@@ -82,8 +90,31 @@8290             }8391 8492             when (val s = state) {85-                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")93+                is CodePairUiState.Starting -> Headline("Starting…", "Opening a secure relay pairing room.")8694 95+                is CodePairUiState.ShowingInvitation -> {96+                    val clipboard = LocalClipboardManager.current97+                    Headline(98+                        "Scan this in Peach",99+                        "Connecting via secure relay — works anywhere. No vault or identity information is in this QR.",100+                    )101+                    Spacer(Modifier.height(PeachSpacing.md))102+                    PairingQrCode(103+                        value = s.invitation,104+                        modifier = Modifier105+                            .fillMaxWidth()106+                            .height(260.dp),107+                    )108+                    Spacer(Modifier.height(PeachSpacing.md))109+                    PillButton("Copy invitation", LocalPeachAccent.current, filled = false) {110+                        clipboard.setText(AnnotatedString(s.invitation))111+                    }112+                    Spacer(Modifier.height(PeachSpacing.sm))113+                    PillButton("Use local network only", peachColors.T2, filled = false) {114+                        vm.useLanOnly(true)115+                    }116+                }117+87118                 is CodePairUiState.ShowingCode -> {88119                     Headline(89120                         "Type this on your computer",@@ -115,6 +146,10 @@115146                         textAlign = TextAlign.Center,116147                         modifier = Modifier.fillMaxWidth(),117148                     )149+                    Spacer(Modifier.height(PeachSpacing.sm))150+                    PillButton("Use secure relay instead", peachColors.T2, filled = false) {151+                        vm.useLanOnly(false)152+                    }118153                 }119154 120155                 is CodePairUiState.ConfirmSas -> {@@ -140,7 +175,7 @@140175                 }141176 142177                 is CodePairUiState.Paired -> {143-                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")178+                    Headline("Paired", "${s.deviceName} will now keep your vault in step through the secure relay.")144179                     Spacer(Modifier.height(PeachSpacing.lg))145180                     PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)146181                 }@@ -161,6 +196,31 @@161196                 }162197             }163198             Spacer(Modifier.height(PeachSpacing.md))199+        }200+    }201+}202+203+@Composable204+private fun PairingQrCode(value: String, modifier: Modifier = Modifier) {205+    val matrix = remember(value) {206+        QRCodeWriter().encode(value, BarcodeFormat.QR_CODE, 0, 0)207+    }208+    Canvas(modifier = modifier) {209+        val side = minOf(size.width, size.height)210+        val module = side / matrix.width211+        val qrSize = module * matrix.width212+        val origin = Offset((size.width - qrSize) / 2f, (size.height - qrSize) / 2f)213+        drawRect(Color.White, topLeft = origin, size = Size(qrSize, qrSize))214+        for (y in 0 until matrix.height) {215+            for (x in 0 until matrix.width) {216+                if (matrix[x, y]) {217+                    drawRect(218+                        Color.Black,219+                        topLeft = Offset(origin.x + x * module, origin.y + y * module),220+                        size = Size(module + 0.5f, module + 0.5f),221+                    )222+                }223+            }164224         }165225     }166226 }

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+125 −13
@@ -10,11 +10,20 @@1010 import com.peachpasswords.sync.PairingEvent1111 import com.peachpasswords.sync.PairingSession1212 import com.peachpasswords.sync.PeachSyncService13+import com.peachpasswords.sync.RelayAdoptionEvent14+import com.peachpasswords.sync.RelayAdoptionHost15+import com.peachpasswords.sync.RelaySettingsStore16+import com.peachpasswords.sync.AdoptionSourceDeps17+import com.peachpasswords.sync.AdoptionFrame18+import com.peachpasswords.sync.LocalChirpIdentity19+import com.peachpasswords.sync.PairedDevice20+import com.peachpasswords.sync.PairedDeviceStore1321 import com.peachpasswords.sync.buildLocalIdentity1422 import com.peachpasswords.sync.deriveLocalSyncKeypair1523 import com.peachpasswords.sync.getOrCreateDeviceId1624 import com.peachpasswords.vault.VaultCrypto1725 import com.peachpasswords.vault.VaultRepository26+import com.peachpasswords.license.LicenseRepository1827 import dagger.hilt.android.lifecycle.HiltViewModel1928 import dagger.hilt.android.qualifiers.ApplicationContext2029 import kotlinx.coroutines.flow.MutableStateFlow@@ -32,6 +41,7 @@3241  */3342 sealed interface CodePairUiState {3443     data object Starting : CodePairUiState44+    data class ShowingInvitation(val invitation: String) : CodePairUiState3545     data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState3646     data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState3747     data class Paired(val deviceName: String) : CodePairUiState@@ -43,6 +53,10 @@4353 class CodePairViewModel @Inject constructor(4454     private val vault: VaultRepository,4555     private val server: CodePairingServer,56+    private val relayHost: RelayAdoptionHost,57+    private val relaySettings: RelaySettingsStore,58+    private val deviceStore: PairedDeviceStore,59+    private val license: LicenseRepository,4660     @ApplicationContext private val appContext: Context,4761 ) : ViewModel() {4862 @@ -66,7 +80,13 @@6680         val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))6781         val identity = buildLocalIdentity(appContext, master)6882         VaultCrypto.zero(master)83+        val sourceDeps = sourceDeps(identity)6984 85+        if (!relaySettings.isLanOnly()) {86+            startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)87+            return88+        }89+7090         // The handler blocks inside the live connection while the user compares7191         // digits, so the SAS has to reach the UI out of band rather than as the7292         // connection's result.@@ -75,36 +95,128 @@7595         }7696 7797         viewModelScope.launch {78-            server.start(viewModelScope, keys.secretKey, keys.publicKey, identity).collect { event ->79-                _state.value = when (event) {80-                    is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)81-                    is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)82-                    PairingEvent.Expired -> CodePairUiState.Expired83-                    is PairingEvent.Rejected -> {84-                        // Nothing else records why a pairing attempt died, and85-                        // the on-screen copy is deliberately vague. Without this86-                        // a failure on someone else's phone is undiagnosable.87-                        if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")88-                        CodePairUiState.Failed(event.reason)98+            try {99+                server.start(100+                    viewModelScope,101+                    keys.secretKey,102+                    keys.publicKey,103+                    identity,104+                    adoptionSourceDeps = sourceDeps,105+                ).collect { event ->106+                    _state.value = when (event) {107+                        is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)108+                        is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)109+                        PairingEvent.Expired -> CodePairUiState.Expired110+                        is PairingEvent.Rejected -> {111+                            // Nothing else records why a pairing attempt died, and112+                            // the on-screen copy is deliberately vague. Without this113+                            // a failure on someone else's phone is undiagnosable.114+                            if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")115+                            CodePairUiState.Failed(event.reason)116+                        }117+                        // Delivered via confirmationSink; the flow only sees the outcome.118+                        is PairingEvent.AwaitingConfirmation -> _state.value89119                     }90-                    // Delivered via confirmationSink; the flow only sees the outcome.91-                    is PairingEvent.AwaitingConfirmation -> _state.value92120                 }121+            } finally {122+                VaultCrypto.zero(keys.secretKey)123+                VaultCrypto.zero(keys.publicKey)93124             }94125         }95126     }96127 128+    private fun startRelay(129+        privateKey: ByteArray,130+        publicKey: ByteArray,131+        identity: LocalChirpIdentity,132+        sourceDeps: AdoptionSourceDeps,133+    ) {134+        viewModelScope.launch {135+            try {136+                relayHost.start(137+                    viewModelScope,138+                    privateKey,139+                    publicKey,140+                    identity,141+                    sourceDeps,142+                ).collect { event ->143+                    _state.value = when (event) {144+                        is RelayAdoptionEvent.InvitationReady ->145+                            CodePairUiState.ShowingInvitation(event.invitation)146+                        is RelayAdoptionEvent.AwaitingConfirmation ->147+                            CodePairUiState.ConfirmSas(148+                                event.sas,149+                                event.deviceName,150+                                event.session,151+                            )152+                        is RelayAdoptionEvent.Adopted ->153+                            CodePairUiState.Paired(event.deviceName)154+                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired155+                        is RelayAdoptionEvent.Rejected -> {156+                            if (BuildConfig.DEBUG) {157+                                Log.w(158+                                    "PeachPairing",159+                                    "relay adoption rejected: ${event.reason}",160+                                )161+                            }162+                            CodePairUiState.Failed(event.reason)163+                        }164+                    }165+                }166+            } finally {167+                VaultCrypto.zero(privateKey)168+                VaultCrypto.zero(publicKey)169+            }170+        }171+    }172+173+    private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =174+        object : AdoptionSourceDeps {175+            override val sourceIdentity: LocalChirpIdentity = identity176+177+            override suspend fun rootKeyHex(): String {178+                val root = vault.syncMasterKey()179+                    ?: throw IllegalStateException("vault_locked")180+                return try {181+                    root.joinToString("") { "%02x".format(it) }182+                } finally {183+                    VaultCrypto.zero(root)184+                }185+            }186+187+            override suspend fun vaultId(): String =188+                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")189+190+            override suspend fun license(): String? =191+                license.getLicenseKey()?.takeIf(license::validateLicense)192+193+            // RelayAdoptionHost owns the actual UI consent gate so it can bind194+            // that decision to the session SAS before delegating here.195+            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true196+197+            override suspend fun upsertPairedDevice(device: PairedDevice) {198+                deviceStore.upsert(device)199+            }200+        }201+97202     fun confirmSas(matches: Boolean) {98203         (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)99204     }100205 101206     fun restart() {207+        relayHost.stop()102208         server.stop()103209         _state.value = CodePairUiState.Starting104210         start()105211     }106212 213+    fun useLanOnly(lanOnly: Boolean) {214+        relaySettings.setLanOnly(lanOnly)215+        restart()216+    }217+107218     fun stop() {219+        relayHost.stop()108220         server.confirmationSink = null109221         server.stop()110222     }

view file

app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+107 −0
@@ -11,6 +11,7 @@1111 import okhttp3.WebSocket1212 import okhttp3.WebSocketListener1313 import okio.ByteString.Companion.toByteString14+import org.json.JSONObject1415 import org.junit.Assert.assertEquals1516 import org.junit.Assert.assertTrue1617 import org.junit.Test@@ -134,6 +135,112 @@134135                 c.frames.isEmpty(),135136             )136137             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)137244         } finally {138245             srv.stop()139246         }

view file

app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+29 −0
@@ -0,0 +1,29 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PairingInvitationTest {8+    private val token = "AAECAwQFBgcICQoLDA0ODw"9+    private val relay = "wss://relay.peachpasswords.com"10+    private val kat =11+        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"12+13+    @Test fun `matches browser invitation KAT`() {14+        assertEquals(kat, createPairingInvitation("$relay/", token))15+        assertEquals(PairingInvitation(relay, token), parsePairingInvitation(kat))16+        assertEquals("$relay/r/$token", parsePairingInvitation(kat)!!.roomUrl())17+    }18+19+    @Test fun `rejects malformed insecure and short-token invitations`() {20+        assertNull(parsePairingInvitation("not-an-invitation"))21+        assertNull(parsePairingInvitation("peach-pair-v1:%%%"))22+        assertNull(parsePairingInvitation(raw("""{"relay":"ws://relay.test","token":"$token"}""")))23+        assertNull(parsePairingInvitation(raw("""{"relay":"$relay","token":"short"}""")))24+    }25+26+    private fun raw(json: String): String =27+        "peach-pair-v1:" + java.util.Base64.getUrlEncoder().withoutPadding()28+            .encodeToString(json.toByteArray())29+}

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+177 −0
@@ -0,0 +1,177 @@1+package com.peachpasswords.sync2+3+import java.util.concurrent.atomic.AtomicBoolean4+import kotlinx.coroutines.async5+import kotlinx.coroutines.channels.Channel6+import kotlinx.coroutines.runBlocking7+import org.json.JSONObject8+import org.junit.Assert.assertEquals9+import org.junit.Assert.assertFalse10+import org.junit.Assert.assertNotNull11+import org.junit.Assert.assertNull12+import org.junit.Test13+14+class RelayAdoptionHostTest {15+    private class MemoryRelaySocket(16+        private val inbound: Channel<ByteArray>,17+        private val outbound: Channel<ByteArray>,18+    ) : RelayBinarySocket {19+        override fun send(frame: ByteArray) {20+            check(outbound.trySend(frame.copyOf()).isSuccess)21+        }22+23+        override suspend fun receive(): ByteArray = inbound.receive()24+        override fun close(code: Int, reason: String) {25+            outbound.close()26+        }27+    }28+29+    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {30+        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)31+        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)32+        return MemoryRelaySocket(rightToLeft, leftToRight) to33+            MemoryRelaySocket(leftToRight, rightToLeft)34+    }35+36+    private fun identity(name: String, publicKey: ByteArray) = LocalChirpIdentity(37+        deviceId = "$name-id",38+        deviceName = name,39+        deviceType = if (name == "source") DeviceType.ANDROID else DeviceType.EXTENSION,40+        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),41+        signingPublicKeyHex = "cd".repeat(32),42+        wsPort = 49142,43+        relayUrl = "wss://relay.example.test",44+    )45+46+    @Test fun `relay invitation runs Noise and persists session-derived pair state`() = runBlocking {47+        val (sourceSocket, receiverSocket) = socketPair()48+        val token = "AAECAwQFBgcICQoLDA0ODw"49+        val relay = "wss://relay.example.test"50+        val sourceRoot = ByteArray(32) { (it + 1).toByte() }51+        val sourceKeys = deriveLocalSyncKeypair(sourceRoot, "source-id")52+        val receiverKeys = deriveLocalSyncKeypair(sourceRoot, "receiver-id")53+        val sourceIdentity = identity("source", sourceKeys.publicKey)54+        val receiverIdentity = identity("receiver", receiverKeys.publicKey)55+        var sourceStored: PairedDevice? = null56+        var receiverStored: PairedDevice? = null57+58+        val sourceJob = async {59+            runRelayAdoptionSourceSession(60+                socket = sourceSocket,61+                expectedToken = token,62+                relayUrl = relay,63+                ourStaticPrivateKey = sourceKeys.secretKey,64+                ourStaticPublicKey = sourceKeys.publicKey,65+                localIdentity = sourceIdentity,66+                sourceDeps = object : AdoptionSourceDeps {67+                    override val sourceIdentity = sourceIdentity68+                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)69+                    override suspend fun vaultId() = computeVaultId(sourceRoot)70+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true71+                    override suspend fun upsertPairedDevice(device: PairedDevice) {72+                        sourceStored = device73+                    }74+                },75+                confirm = { sas, request ->76+                    assertEquals("receiver", request.deviceName)77+                    assertEquals(6, sas.length)78+                    true79+                },80+            )81+        }82+83+        receiverSocket.send(84+            JSONObject().put("type", "PEACH_HELLO").put("token", token)85+                .toString().toByteArray(),86+        )87+        val ack = JSONObject(String(receiverSocket.receive()))88+        val sourcePublic = hexToPubkey(ack.getString("ephemeralPublicKeyHex"))89+        val noise = noiseNKInitiator(90+            sourcePublic,91+            receiverSocket::send,92+            receiverSocket::receive,93+        )94+        val channel = encryptedChannel(receiverSocket, noise)95+        val received = requestAdoption(96+            channel,97+            object : AdoptionReceiverDeps {98+                override val deviceName = "receiver"99+                override val deviceType = DeviceType.EXTENSION100+                override val pairState = AdoptionPairState(101+                    Rendezvous.bytesToHex(Rendezvous.deriveSecret(noise.handshakeHash)),102+                    relay,103+                )104+                override fun generateDeviceId() = "session-request"105+                override suspend fun confirmSas() = true106+                override suspend fun createVaultFromRootKey(rootKeyHex: String) =107+                    computeVaultId(hexToPubkey(rootKeyHex))108+                override suspend fun deriveLocalIdentity() = receiverIdentity109+                override suspend fun upsertPairedDevice(device: PairedDevice) {110+                    receiverStored = device111+                }112+                override fun onError(error: String) = throw AssertionError(error)113+            },114+        )115+116+        assertNotNull(received)117+        assertNotNull(sourceJob.await())118+        assertEquals(relay, receiverStored?.relayUrl)119+        assertEquals(relay, sourceStored?.relayUrl)120+        assertEquals(receiverStored?.rendezvousSecretHex, sourceStored?.rendezvousSecretHex)121+        assertEquals(64, receiverStored?.rendezvousSecretHex?.length)122+    }123+124+    @Test fun `source rejection never requests the root`() = runBlocking {125+        val (sourceSocket, receiverSocket) = socketPair()126+        val token = "AAECAwQFBgcICQoLDA0ODw"127+        val root = ByteArray(32) { (it + 7).toByte() }128+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")129+        val rootRequested = AtomicBoolean(false)130+131+        val sourceJob = async {132+            runRelayAdoptionSourceSession(133+                sourceSocket,134+                token,135+                "wss://relay.example.test",136+                sourceKeys.secretKey,137+                sourceKeys.publicKey,138+                identity("source", sourceKeys.publicKey),139+                object : AdoptionSourceDeps {140+                    override val sourceIdentity = identity("source", sourceKeys.publicKey)141+                    override suspend fun rootKeyHex(): String {142+                        rootRequested.set(true)143+                        return Rendezvous.bytesToHex(root)144+                    }145+                    override suspend fun vaultId() = computeVaultId(root)146+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true147+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit148+                },149+                confirm = { _, _ -> false },150+            )151+        }152+        receiverSocket.send(153+            JSONObject().put("type", "PEACH_HELLO").put("token", token)154+                .toString().toByteArray(),155+        )156+        val ack = JSONObject(String(receiverSocket.receive()))157+        val noise = noiseNKInitiator(158+            hexToPubkey(ack.getString("ephemeralPublicKeyHex")),159+            receiverSocket::send,160+            receiverSocket::receive,161+        )162+        val channel = encryptedChannel(receiverSocket, noise)163+        sendFrame(channel, AdoptionFrame.Request("session-request", "receiver", "extension"))164+165+        assertNull(sourceJob.await())166+        assertFalse(rootRequested.get())167+    }168+169+    private fun encryptedChannel(170+        socket: RelayBinarySocket,171+        noise: NoiseNKSession,172+    ): PairingTransport = object : PairingTransport {173+        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))174+        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())175+        override suspend fun close() = socket.close()176+    }177+}

view file