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
↓ peach-android-7c32509a.tar.gzatlas at this save
files changed7 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 −0app/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 {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 fileapp/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 fileapp/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) }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 fileapp/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 fileapp/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