7c32509a6aa3

phone reads vaultStatus from the scanned invitation and switches roles: 'empty' → grantAdoption (send the vault to the empty browser), 'populated' or missing → existing receiver flow unchanged. Defaults to 'populated' for older invitations so historical sync is unaffected. (v2.7.12)

time
2026-07-28 13:57 UTC · 2 months ago
machine
koh2
agent
claude-MiniMax-M3
session
sync red indicator port from browser
parent
4e097b1b4521
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+37 −4app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+140 −0app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+8 −1app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+121 −4app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+46 −5app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+286 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24013820-        versionName "2.7.11"19+        versionCode 24013920+        versionName "2.7.12"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/PairingInvitation.kt+37 −4
@@ -8,30 +8,49 @@88 private const val PAIRING_INVITATION_PREFIX = "peach-pair-v1:"99 private val PairingTokenPattern = Regex("^[A-Za-z0-9_-]{22}$")1010 11+/**12+ * Which side of the vault transfer the issuing device is on:13+ *   POPULATED — issuer has the vault; the scanner receives it (historical default).14+ *   EMPTY     — issuer has no vault; the scanner (phone) is the source and sends it.15+ *16+ * Older invitations from before this field existed parse as POPULATED, so a17+ * scanner that doesn't know about `vaultStatus` still does the right thing:18+ * it receives the vault from the source.19+ */20+enum class VaultStatus { EMPTY, POPULATED }21+1122 data class PairingInvitation(1223     val relayUrl: String,1324     val token: String,25+    val vaultStatus: VaultStatus = VaultStatus.POPULATED,1426 )1527 1628 @Serializable1729 private data class PairingInvitationWire(1830     val relay: String,1931     val token: String,32+    val vault: String? = null,2033 )2134 2235 /**2336  * 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.37+ * a credential-free wss relay and a fresh 128-bit token. [vaultStatus] tells38+ * the scanner which side of the transfer it is on; older invitations omit it39+ * and parse as POPULATED. Never add identity or vault fields here.2640  */2741 fun createPairingInvitation(2842     relayUrl: String,2943     token: String = Rendezvous.newPairingToken(),44+    vaultStatus: VaultStatus = VaultStatus.POPULATED,3045 ): String {3146     require(PairingTokenPattern.matches(token)) {3247         "pairing invitation token must be 128-bit base64url"3348     }34-    val wire = PairingInvitationWire(Rendezvous.normalizeRelayUrl(relayUrl), token)49+    val wire = PairingInvitationWire(50+        relay = Rendezvous.normalizeRelayUrl(relayUrl),51+        token = token,52+        vault = wireVaultStatus(vaultStatus),53+    )3554     val encoded = Base64.getUrlEncoder().withoutPadding()3655         .encodeToString(Json.encodeToString(wire).toByteArray(Charsets.UTF_8))3756     return PAIRING_INVITATION_PREFIX + encoded@@ -51,8 +70,22 @@5170         PairingInvitation(5271             relayUrl = Rendezvous.normalizeRelayUrl(wire.relay),5372             token = wire.token,73+            vaultStatus = parseVaultStatus(wire.vault),5474         )5575     }.getOrNull()5676 }5777 58-fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)78+private fun wireVaultStatus(status: VaultStatus): String = when (status) {79+    VaultStatus.EMPTY -> "empty"80+    VaultStatus.POPULATED -> "populated"81+}82+83+private fun parseVaultStatus(raw: String?): VaultStatus = when (raw) {84+    "empty" -> VaultStatus.EMPTY85+    "populated" -> VaultStatus.POPULATED86+    // Missing or unknown defaults to POPULATED so historical invitations keep87+    // working byte-for-byte: the scanner receives the vault from the source.88+    else -> VaultStatus.POPULATED89+}90+91+fun PairingInvitation.roomUrl(): String = Rendezvous.buildUrl(relayUrl, token)\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+140 −0
@@ -13,6 +13,11 @@1313  */1414 @Singleton1515 class RelayAdoptionReceiver @Inject constructor() {16+    /**17+     * Phone as receiver: scan a [VaultStatus.POPULATED] invitation, dial the18+     * relay room, complete Noise, and receive the source's root key. This is19+     * the historical pairing direction and stays byte-for-byte unchanged.20+     */1621     suspend fun adopt(1722         encodedInvitation: String,1823         deps: AdoptionReceiverDeps,@@ -21,6 +26,9 @@2126     ): PairedDevice {2227         val invitation = parsePairingInvitation(encodedInvitation)2328             ?: throw IllegalArgumentException("invalid_pairing_invitation")29+        require(invitation.vaultStatus == VaultStatus.POPULATED) {30+            "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"31+        }2432         val socket = OkHttpRelaySocket.connect(invitation.roomUrl())2533         return adoptFromRelaySocket(2634             socket = socket,@@ -30,6 +38,34 @@3038             windowMs = windowMs,3139         )3240     }41+42+    /**43+     * Phone as source: scan a [VaultStatus.EMPTY] invitation from an empty44+     * device, dial the relay room, complete Noise, and hand our root key over.45+     * Symmetric to [adopt]: the initiator runs Noise NK, but the payload46+     * direction flips — the phone sends the grant, the empty device receives47+     * it.48+     */49+    suspend fun grantFromInvitation(50+        encodedInvitation: String,51+        sourceDeps: AdoptionSourceDeps,52+        confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,53+        windowMs: Long = PAIRING_WINDOW_MS,54+    ): PairedDevice {55+        val invitation = parsePairingInvitation(encodedInvitation)56+            ?: throw IllegalArgumentException("invalid_pairing_invitation")57+        require(invitation.vaultStatus == VaultStatus.EMPTY) {58+            "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"59+        }60+        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())61+        return grantFromRelaySocket(62+            socket = socket,63+            invitation = invitation,64+            sourceDeps = sourceDeps,65+            confirm = confirm,66+            windowMs = windowMs,67+        )68+    }3369 }3470 3571 /**@@ -70,6 +106,40 @@70106 }71107 72108 /**109+ * Source-initiator counterpart to [adoptFromRelaySocket]: phone dials the110+ * relay room, runs Noise as initiator, and hands the root key over to the111+ * empty responder. The final source action is an encrypted ADOPT_COMPLETE,112+ * so a graceful close is required to land it.113+ */114+internal suspend fun grantFromRelaySocket(115+    socket: RelayBinarySocket,116+    invitation: PairingInvitation,117+    sourceDeps: AdoptionSourceDeps,118+    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,119+    windowMs: Long = PAIRING_WINDOW_MS,120+): PairedDevice {121+    var completed = false122+    return try {123+        val granted = withTimeout(windowMs) {124+            runRelayAdoptionInitiatorSourceSession(125+                socket = socket,126+                invitation = invitation,127+                sourceDeps = sourceDeps,128+                confirm = confirm,129+            ) ?: throw IllegalStateException("adoption_rejected")130+        }131+        completed = true132+        granted133+    } finally {134+        if (completed) {135+            socket.closeAfterQueuedFrames()136+        } else {137+            socket.close()138+        }139+    }140+}141+142+/**73143  * Pure live-session seam used by both production and the real Noise interop74144  * test. The source must already occupy the relay room before this starts.75145  */@@ -152,3 +222,73 @@152222         noise.close()153223     }154224 }225+226+/**227+ * Phone-initiator source session for the "empty browser" invitation path.228+ * The phone dials the relay room and waits for the empty responder to send229+ * ADOPT_REQUEST, then hands over its root key through [grantAdoption]. The230+ * SAS gate is the single commit signal — same model as the existing source231+ * session — and onConsent is pre-cleared because the user already opted in232+ * by scanning the QR.233+ */234+internal suspend fun runRelayAdoptionInitiatorSourceSession(235+    socket: RelayBinarySocket,236+    invitation: PairingInvitation,237+    sourceDeps: AdoptionSourceDeps,238+    confirm: suspend (sas: String, request: AdoptionFrame.Request) -> Boolean,239+): PairedDevice? {240+    socket.send(241+        JSONObject()242+            .put("type", "PEACH_HELLO")243+            .put("token", invitation.token)244+            .toString()245+            .toByteArray(Charsets.UTF_8),246+    )247+248+    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))249+    require(ack.optString("type") == "PEACH_HELLO_ACK") {250+        "expected PEACH_HELLO_ACK"251+    }252+    val responderStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))253+    val noise = try {254+        noiseNKInitiator(255+            responderStatic,256+            socket::send,257+            socket::receive,258+        )259+    } finally {260+        responderStatic.fill(0)261+    }262+    try {263+        val channel = object : PairingTransport {264+            override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))265+            override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())266+            override suspend fun close() = socket.close()267+        }268+        val request = receiveFrame(channel)269+        if (!isAdoptRequest(request)) return null270+        request as AdoptionFrame.Request271+        if (!confirm(SASDerivation.derive(noise.handshakeHash), request)) return null272+273+        val pairState = AdoptionPairState(274+            rendezvousSecretHex = Rendezvous.bytesToHex(275+                Rendezvous.deriveSecret(noise.handshakeHash),276+            ),277+            relayUrl = invitation.relayUrl,278+        )279+        val boundDeps = object : AdoptionSourceDeps {280+            override val sourceIdentity = sourceDeps.sourceIdentity281+            override val pairState = pairState282+            override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()283+            override suspend fun vaultId(): String = sourceDeps.vaultId()284+            override suspend fun license(): String? = sourceDeps.license()285+            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true286+            override suspend fun upsertPairedDevice(device: PairedDevice) {287+                sourceDeps.upsertPairedDevice(device)288+            }289+        }290+        return grantAdoption(channel, request, boundDeps)291+    } finally {292+        noise.close()293+    }294+}\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+8 −1
@@ -211,6 +211,9 @@211211                     Body(212212                         "Only continue if this code is identical on the device sharing the vault.",213213                     )214+                    current.peerName?.let { name ->215+                        Body("Joining device: $name")216+                    }214217                     Spacer(Modifier.height(PeachSpacing.lg))215218                     Text(216219                         current.sas,@@ -221,7 +224,11 @@221224                     )222225                     Spacer(Modifier.height(PeachSpacing.lg))223226                     PillButton(224-                        if (vm.joining) "They match — join vault" else "They match — adopt vault",227+                        when {228+                            current.isSource -> "They match — send vault"229+                            vm.joining -> "They match — join vault"230+                            else -> "They match — adopt vault"231+                        },225232                         LocalPeachAccent.current,226233                         filled = true,227234                     ) { vm.confirmSas(true) }

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+121 −4
@@ -5,6 +5,7 @@55 import androidx.lifecycle.viewModelScope66 import com.peachpasswords.license.LicenseRepository77 import com.peachpasswords.sync.AdoptionReceiverDeps8+import com.peachpasswords.sync.AdoptionSourceDeps89 import com.peachpasswords.sync.BackgroundSyncPreference910 import com.peachpasswords.sync.DeviceType1011 import com.peachpasswords.sync.AdoptionFrame@@ -14,9 +15,13 @@1415 import com.peachpasswords.sync.PeachSyncService1516 import com.peachpasswords.sync.RelayAdoptionReceiver1617 import com.peachpasswords.sync.RelaySettingsStore18+import com.peachpasswords.sync.RelayDecision19+import com.peachpasswords.sync.VaultStatus1720 import com.peachpasswords.sync.buildLocalIdentity1821 import com.peachpasswords.sync.computeVaultId22+import com.peachpasswords.sync.deriveLocalSyncKeypair1923 import com.peachpasswords.sync.getDeviceName24+import com.peachpasswords.sync.getOrCreateDeviceId2025 import com.peachpasswords.sync.hexToPubkey2126 import com.peachpasswords.sync.parsePairingInvitation2227 import com.peachpasswords.ui.security.scorePassword@@ -45,7 +50,16 @@4550     data object Disclosure : AdoptVaultUiState4651     data object Pair : AdoptVaultUiState4752     data object Connecting : AdoptVaultUiState48-    data class ConfirmSas(val sas: String) : AdoptVaultUiState53+    /**54+     * Source-initiator flow exposes [isSource] so the screen can phrase the55+     * confirmation as "send vault" instead of "adopt/join vault". The SAS is56+     * identical to the receiver flow; only the payload direction differs.57+     */58+    data class ConfirmSas(59+        val sas: String,60+        val isSource: Boolean = false,61+        val peerName: String? = null,62+    ) : AdoptVaultUiState4963     data object Adopting : AdoptVaultUiState5064     data class Complete(val sourceName: String) : AdoptVaultUiState5165     data class Failed(val reason: String) : AdoptVaultUiState@@ -96,11 +110,19 @@96110 97111     fun adopt(invitation: String) {98112         if (_state.value !is AdoptVaultUiState.Pair) return99-        if (parsePairingInvitation(invitation) == null) {113+        val parsed = parsePairingInvitation(invitation)114+        if (parsed == null) {100115             _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")101116             clearPassword()102117             return103118         }119+        when (parsed.vaultStatus) {120+            VaultStatus.POPULATED -> startReceiverFlow(invitation)121+            VaultStatus.EMPTY -> startSourceFlow(invitation)122+        }123+    }124+125+    private fun startReceiverFlow(invitation: String) {104126         _state.value = AdoptVaultUiState.Connecting105127         protocolError = null106128         adoptionJob = viewModelScope.launch {@@ -111,7 +133,10 @@111133                     confirm = { sas ->112134                         val pending = CompletableDeferred<Boolean>()113135                         confirmation = pending114-                        _state.value = AdoptVaultUiState.ConfirmSas(sas)136+                        _state.value = AdoptVaultUiState.ConfirmSas(137+                            sas = sas,138+                            isSource = false,139+                        )115140                         pending.await()116141                     },117142                 )@@ -137,6 +162,70 @@137162         }138163     }139164 165+    /**166+     * Source-initiator flow: the scanning invitation came from a vault-less167+     * device, so the phone dials the relay, completes Noise, and sends the168+     * root key over. Uses the same UI states as the receiver flow so the169+     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]170+     * distinguishes the role for the confirmation copy.171+     */172+    private fun startSourceFlow(invitation: String) {173+        val master = vault.syncMasterKey()174+        if (master == null) {175+            _state.value = AdoptVaultUiState.Failed("vault_locked")176+            clearPassword()177+            return178+        }179+        when (val decision = relaySettings.relayInForce()) {180+            is RelayDecision.Refuse -> {181+                VaultCrypto.zero(master)182+                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())183+                clearPassword()184+                return185+            }186+            is RelayDecision.Dial -> Unit187+        }188+        _state.value = AdoptVaultUiState.Connecting189+        protocolError = null190+191+        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))192+        val identity = buildLocalIdentity(appContext, master)193+        VaultCrypto.zero(master)194+        adoptionJob = viewModelScope.launch {195+            try {196+                val paired = receiver.grantFromInvitation(197+                    encodedInvitation = invitation,198+                    sourceDeps = sourceDeps(identity),199+                    confirm = { sas, request ->200+                        val pending = CompletableDeferred<Boolean>()201+                        confirmation = pending202+                        _state.value = AdoptVaultUiState.ConfirmSas(203+                            sas = sas,204+                            isSource = true,205+                            peerName = request.deviceName.ifBlank { "New device" },206+                        )207+                        pending.await()208+                    },209+                )210+                paired.relayUrl?.let(relaySettings::setRelayUrl)211+                _state.value = AdoptVaultUiState.Complete(212+                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",213+                )214+            } catch (error: CancellationException) {215+                throw error216+            } catch (error: Throwable) {217+                _state.value = AdoptVaultUiState.Failed(218+                    protocolError ?: error.message ?: "adoption_failed",219+                )220+            } finally {221+                VaultCrypto.zero(keys.secretKey)222+                VaultCrypto.zero(keys.publicKey)223+                confirmation = null224+                clearPassword()225+            }226+        }227+    }228+140229     fun confirmSas(matches: Boolean) {141230         val pending = confirmation ?: return142231         confirmation = null@@ -231,6 +320,34 @@231320             }232321         }233322 323+    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =324+        object : AdoptionSourceDeps {325+            override val sourceIdentity: LocalDeviceIdentity = identity326+327+            override suspend fun rootKeyHex(): String {328+                val root = vault.syncMasterKey()329+                    ?: throw IllegalStateException("vault_locked")330+                return try {331+                    root.joinToString("") { "%02x".format(it) }332+                } finally {333+                    VaultCrypto.zero(root)334+                }335+            }336+337+            override suspend fun vaultId(): String =338+                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")339+340+            override suspend fun license(): String? =341+                license.getLicenseKey()?.takeIf(license::validateLicense)342+343+            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true344+345+            override suspend fun upsertPairedDevice(device: PairedDevice) {346+                devices.clearRevocation(device.deviceId)347+                devices.upsert(device)348+            }349+        }350+234351     fun setBackgroundSync(enabled: Boolean) {235352         backgroundSync.setEnabled(enabled)236353         if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)@@ -262,4 +379,4 @@262379         throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")263380     }264381     return localVaultId265-}382+}\ No newline at end of file

view file

app/src/test/java/com/peachpasswords/sync/PairingInvitationTest.kt+46 −5
@@ -1,6 +1,7 @@11 package com.peachpasswords.sync22 33 import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNotNull45 import org.junit.Assert.assertNull56 import org.junit.Test67 @@ -8,14 +9,54 @@89     private val token = "AAECAwQFBgcICQoLDA0ODw"910     private val relay = "wss://relay.peachpasswords.com"1011     private val kat =11-        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"12+        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0"13+    private val emptyKat =14+        "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoiZW1wdHkifQ"1215 13-    @Test fun `matches browser invitation KAT`() {16+    @Test fun `matches browser invitation KAT with default populated vault`() {1417         assertEquals(kat, createPairingInvitation("$relay/", token))15-        assertEquals(PairingInvitation(relay, token), parsePairingInvitation(kat))16-        assertEquals("$relay/r/$token", parsePairingInvitation(kat)!!.roomUrl())18+        val parsed = parsePairingInvitation(kat)19+        assertEquals(PairingInvitation(relay, token, VaultStatus.POPULATED), parsed)20+        assertEquals("$relay/r/$token", parsed!!.roomUrl())1721     }1822 23+    @Test fun `empty invitation round-trips as source role`() {24+        assertEquals(emptyKat, createPairingInvitation(relay, token, VaultStatus.EMPTY))25+        val parsed = parsePairingInvitation(emptyKat)26+        assertEquals(PairingInvitation(relay, token, VaultStatus.EMPTY), parsed)27+    }28+29+    @Test fun `explicit populated invitation round-trips as receiver role`() {30+        val invitation = createPairingInvitation(relay, token, VaultStatus.POPULATED)31+        val parsed = parsePairingInvitation(invitation)32+        assertEquals(VaultStatus.POPULATED, parsed?.vaultStatus)33+    }34+35+    @Test fun `missing vault field defaults to populated for historical compatibility`() {36+        val older = raw("""{"relay":"$relay","token":"$token"}""")37+        val parsed = parsePairingInvitation(older)38+        assertEquals(VaultStatus.POPULATED, parsed?.vaultStatus)39+        assertEquals(PairingInvitation(relay, token), parsed)40+    }41+42+    @Test fun `unknown vault value defaults to populated`() {43+        val weird = raw("""{"relay":"$relay","token":"$token","vault":"maybe"}""")44+        val parsed = parsePairingInvitation(weird)45+        assertEquals(VaultStatus.POPULATED, parsed?.vaultStatus)46+    }47+48+    @Test fun `vaultStatus on the parsed invitation drives the adoption flow`() {49+        val empty = parsePairingInvitation(emptyKat)50+        val populated = parsePairingInvitation(kat)51+        val legacy = parsePairingInvitation(raw("""{"relay":"$relay","token":"$token"}"""))52+        assertNotNull(empty)53+        assertNotNull(populated)54+        assertNotNull(legacy)55+        assertEquals(VaultStatus.EMPTY, empty!!.vaultStatus)56+        assertEquals(VaultStatus.POPULATED, populated!!.vaultStatus)57+        assertEquals(VaultStatus.POPULATED, legacy!!.vaultStatus)58+    }59+1960     @Test fun `rejects malformed insecure and short-token invitations`() {2061         assertNull(parsePairingInvitation("not-an-invitation"))2162         assertNull(parsePairingInvitation("peach-pair-v1:%%%"))@@ -26,4 +67,4 @@2667     private fun raw(json: String): String =2768         "peach-pair-v1:" + java.util.Base64.getUrlEncoder().withoutPadding()2869             .encodeToString(json.toByteArray())29-}70+}\ No newline at end of file

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+286 −0
@@ -0,0 +1,286 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.runBlocking5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertFalse7+import org.junit.Assert.assertNotNull8+import org.junit.Assert.assertNull9+import org.junit.Assert.assertTrue10+import org.junit.Assert.fail11+import org.junit.Test12+13+/**14+ * Source-initiator counterpart to [RelayAdoptionHostTest]: the phone dials the15+ * relay room, runs Noise as initiator, and sends the grant to the (empty)16+ * responder. The responder side runs the real [requestAdoption] receiver17+ * pipeline so this test exercises both directions of the protocol over a18+ * real Noise channel.19+ */20+class RelayAdoptionInitiatorSourceTest {21+22+    private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(23+        deviceId = "$name-id",24+        deviceName = name,25+        deviceType = if (name == "phone") DeviceType.ANDROID else DeviceType.EXTENSION,26+        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),27+        signingPublicKeyHex = "cd".repeat(32),28+        wsPort = 49142,29+        relayUrl = "wss://relay.example.test",30+    )31+32+    private class MemoryRelaySocket(33+        private val inbound: kotlinx.coroutines.channels.Channel<ByteArray>,34+        private val outbound: kotlinx.coroutines.channels.Channel<ByteArray>,35+    ) : RelayBinarySocket {36+        override fun send(frame: ByteArray) {37+            check(outbound.trySend(frame.copyOf()).isSuccess)38+        }39+40+        override suspend fun receive(): ByteArray = inbound.receive()41+        override fun close(code: Int, reason: String) {42+            outbound.close()43+        }44+    }45+46+    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {47+        val leftToRight = kotlinx.coroutines.channels.Channel<ByteArray>(48+            kotlinx.coroutines.channels.Channel.UNLIMITED,49+        )50+        val rightToLeft = kotlinx.coroutines.channels.Channel<ByteArray>(51+            kotlinx.coroutines.channels.Channel.UNLIMITED,52+        )53+        return MemoryRelaySocket(rightToLeft, leftToRight) to54+            MemoryRelaySocket(leftToRight, rightToLeft)55+    }56+57+    @Test fun `phone source sends grant to empty responder over a real Noise channel`() = runBlocking {58+        val (phoneSocket, responderSocket) = socketPair()59+        val token = "AAECAwQFBgcICQoLDA0ODw"60+        val relay = "wss://relay.example.test"61+        val root = ByteArray(32) { (it + 5).toByte() }62+        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")63+        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")64+        val phoneIdentity = identity("phone", phoneKeys.publicKey)65+        val responderIdentity = identity("responder", responderKeys.publicKey)66+        var phoneStored: PairedDevice? = null67+68+        val phoneJob = async {69+            runRelayAdoptionInitiatorSourceSession(70+                socket = phoneSocket,71+                invitation = PairingInvitation(relay, token, VaultStatus.EMPTY),72+                sourceDeps = object : AdoptionSourceDeps {73+                    override val sourceIdentity = phoneIdentity74+                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)75+                    override suspend fun vaultId() = computeVaultId(root)76+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true77+                    override suspend fun upsertPairedDevice(device: PairedDevice) {78+                        phoneStored = device79+                    }80+                },81+                confirm = { sas, request ->82+                    assertEquals(responderIdentity.deviceName, request.deviceName)83+                    assertEquals("extension", request.deviceType)84+                    assertEquals(8, sas.length)85+                    true86+                },87+            )88+        }89+90+        // Responder (empty browser) handshake: receive HELLO, send ACK, run Noise.91+        val hello = org.json.JSONObject(String(responderSocket.receive()))92+        assertEquals("PEACH_HELLO", hello.optString("type"))93+        assertEquals(token, hello.optString("token"))94+        responderSocket.send(95+            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")96+                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))97+                .toString().toByteArray(),98+        )99+        val noise = noiseNKResponder(100+            responderKeys.secretKey,101+            responderKeys.publicKey,102+            responderSocket::send,103+            responderSocket::receive,104+        )105+        var responderStored: PairedDevice? = null106+        try {107+            val channel = object : PairingTransport {108+                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))109+                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())110+                override suspend fun close() = responderSocket.close()111+            }112+            // Run the real receiver pipeline on the responder side so the test113+            // exercises both protocol directions end-to-end.114+            val responderJob = async {115+                requestAdoption(116+                    channel = channel,117+                    deps = object : AdoptionReceiverDeps {118+                        override val deviceName = responderIdentity.deviceName119+                        override val deviceType = DeviceType.EXTENSION120+                        override fun generateDeviceId() = "session-only"121+                        override suspend fun confirmSas() = true122+                        override suspend fun createVaultFromRootKey(rootKeyHex: String): String {123+                            assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)124+                            return computeVaultId(hexToPubkey(rootKeyHex))125+                        }126+                        override suspend fun deriveLocalIdentity() = responderIdentity127+                        override suspend fun upsertPairedDevice(device: PairedDevice) {128+                            responderStored = device129+                        }130+                        override fun onError(error: String) = throw AssertionError(error)131+                    },132+                )133+            }134+            assertNotNull(responderJob.await())135+        } finally {136+            noise.close()137+        }138+139+        assertNotNull(phoneJob.await())140+        assertNotNull(phoneStored)141+        assertNotNull(responderStored)142+        assertEquals("responder-id", phoneStored?.deviceId)143+        assertEquals(responderIdentity.deviceName, phoneStored?.deviceName)144+        assertEquals(relay, phoneStored?.relayUrl)145+        assertEquals(64, phoneStored?.rendezvousSecretHex?.length)146+        // The responder stores the phone's identity (from the ADOPT_GRANT source).147+        assertEquals("phone-id", responderStored?.deviceId)148+        assertEquals(phoneIdentity.deviceName, responderStored?.deviceName)149+    }150+151+    @Test fun `phone source never reads or sends a grant when SAS is rejected`() = runBlocking {152+        val (phoneSocket, responderSocket) = socketPair()153+        val token = "AAECAwQFBgcICQoLDA0ODw"154+        val root = ByteArray(32) { (it + 11).toByte() }155+        val phoneKeys = deriveLocalSyncKeypair(root, "phone-id")156+        val responderKeys = deriveLocalSyncKeypair(root, "responder-id")157+        var rootRequested = false158+        var upserted = false159+160+        val phoneJob = async {161+            runRelayAdoptionInitiatorSourceSession(162+                socket = phoneSocket,163+                invitation = PairingInvitation(164+                    "wss://relay.example.test",165+                    token,166+                    VaultStatus.EMPTY,167+                ),168+                sourceDeps = object : AdoptionSourceDeps {169+                    override val sourceIdentity = identity("phone", phoneKeys.publicKey)170+                    override suspend fun rootKeyHex(): String {171+                        rootRequested = true172+                        return Rendezvous.bytesToHex(root)173+                    }174+                    override suspend fun vaultId() = computeVaultId(root)175+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true176+                    override suspend fun upsertPairedDevice(device: PairedDevice) {177+                        upserted = true178+                    }179+                },180+                confirm = { _, _ -> false },181+            )182+        }183+184+        // Responder handshake185+        responderSocket.receive() // PEACH_HELLO186+        responderSocket.send(187+            org.json.JSONObject().put("type", "PEACH_HELLO_ACK")188+                .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(responderKeys.publicKey))189+                .toString().toByteArray(),190+        )191+        val noise = noiseNKResponder(192+            responderKeys.secretKey,193+            responderKeys.publicKey,194+            responderSocket::send,195+            responderSocket::receive,196+        )197+        try {198+            val channel = object : PairingTransport {199+                override suspend fun send(msg: ByteArray) = responderSocket.send(noise.encrypt(msg))200+                override suspend fun receive(): ByteArray = noise.decrypt(responderSocket.receive())201+                override suspend fun close() = responderSocket.close()202+            }203+            // Send only the request. The phone's source flow must reject before204+            // it ever reads the root key or sends the grant.205+            sendFrame(channel, AdoptionFrame.Request("session-only", "Empty Browser", "extension"))206+        } finally {207+            noise.close()208+        }209+210+        assertNull(phoneJob.await())211+        assertFalse("root key must stay on this device until SAS matches", rootRequested)212+        assertFalse("paired-device store must not be touched on rejection", upserted)213+    }214+}215+216+/**217+ * Dispatch tests: each receiver entry point rejects the wrong vaultStatus, so218+ * the ViewModel's `when (parsed.vaultStatus)` is forced to call the matching219+ * path. Combined with the parser tests this is the flow-level guarantee that220+ * an empty invitation triggers grantAdoption and a populated one triggers221+ * requestAdoption.222+ */223+class RelayAdoptionReceiverDispatchTest {224+225+    @Test fun `adopt rejects empty invitations so the source path is used instead`() = runBlocking {226+        val receiver = RelayAdoptionReceiver()227+        val empty = createPairingInvitation(228+            "wss://relay.example.test",229+            "AAECAwQFBgcICQoLDA0ODw",230+            VaultStatus.EMPTY,231+        )232+        try {233+            receiver.adopt(234+                encodedInvitation = empty,235+                deps = object : AdoptionReceiverDeps {236+                    override val deviceName = "phone"237+                    override val deviceType = DeviceType.ANDROID238+                    override fun generateDeviceId() = "x"239+                    override suspend fun confirmSas() = true240+                    override suspend fun createVaultFromRootKey(rootKeyHex: String) = "v"241+                    override suspend fun deriveLocalIdentity() = error("unused")242+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit243+                    override fun onError(error: String) = Unit244+                },245+                confirm = { true },246+            )247+            fail("adopt must reject empty invitations")248+        } catch (expected: IllegalArgumentException) {249+            assertTrue(250+                "expected vaultStatus in the error, got: ${expected.message}",251+                expected.message!!.contains("EMPTY", ignoreCase = true),252+            )253+        }254+    }255+256+    @Test fun `grantFromInvitation rejects populated invitations so the receiver path is used instead`() = runBlocking {257+        val receiver = RelayAdoptionReceiver()258+        val populated = createPairingInvitation(259+            "wss://relay.example.test",260+            "AAECAwQFBgcICQoLDA0ODw",261+            VaultStatus.POPULATED,262+        )263+        try {264+            receiver.grantFromInvitation(265+                encodedInvitation = populated,266+                sourceDeps = object : AdoptionSourceDeps {267+                    override val sourceIdentity = LocalDeviceIdentity(268+                        "phone", "phone", DeviceType.ANDROID,269+                        "ab".repeat(32), "cd".repeat(32),270+                    )271+                    override suspend fun rootKeyHex() = "ab".repeat(32)272+                    override suspend fun vaultId() = "cd".repeat(32)273+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true274+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit275+                },276+                confirm = { _, _ -> true },277+            )278+            fail("grantFromInvitation must reject populated invitations")279+        } catch (expected: IllegalArgumentException) {280+            assertTrue(281+                "expected vaultStatus in the error, got: ${expected.message}",282+                expected.message!!.contains("POPULATED", ignoreCase = true),283+            )284+        }285+    }286+}\ No newline at end of file

view file