5e73e5d32f84

feat: add safe relay vault adoption receiver (v2.3.79)

time
2026-07-26 05:37 UTC · 3 months ago
machine
koh2
parent
a3694f583c46
12 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+110 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+23 −1app/src/main/java/com/peachpasswords/ui/PeachNav.kt+1 −0app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+13 −11app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+249 −0app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+181 −0app/src/main/java/com/peachpasswords/ui/pairing/PairingQrCamera.kt+120 −0app/src/main/java/com/peachpasswords/ui/pairing/PairingQrFrame.kt+63 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+1 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+167 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+17 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23780020-        versionName "2.3.78"19+        versionCode 23790020+        versionName "2.3.79"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+110 −0
@@ -0,0 +1,110 @@1+package com.peachpasswords.sync2+3+import javax.inject.Inject4+import javax.inject.Singleton5+import kotlinx.coroutines.withTimeout6+import org.json.JSONObject7+8+/**9+ * Receiver half of relay-first vault adoption.10+ *11+ * The invitation is reachability only. The relay never sees identity, SAS, or12+ * vault material; those enter only after the Noise NK session is established.13+ */14+@Singleton15+class RelayAdoptionReceiver @Inject constructor() {16+    suspend fun adopt(17+        encodedInvitation: String,18+        deps: AdoptionReceiverDeps,19+        confirm: suspend (sas: String) -> Boolean,20+        windowMs: Long = CodePairingServer.PAIRING_WINDOW_MS,21+    ): PairedDevice {22+        val invitation = parsePairingInvitation(encodedInvitation)23+            ?: throw IllegalArgumentException("invalid_pairing_invitation")24+        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())25+        return try {26+            withTimeout(windowMs) {27+                runRelayAdoptionReceiverSession(28+                    socket = socket,29+                    invitation = invitation,30+                    receiverDeps = deps,31+                    confirm = confirm,32+                ) ?: throw IllegalStateException("adoption_rejected")33+            }34+        } finally {35+            socket.close()36+        }37+    }38+}39+40+/**41+ * Pure live-session seam used by both production and the real Noise interop42+ * test. The source must already occupy the relay room before this starts.43+ */44+internal suspend fun runRelayAdoptionReceiverSession(45+    socket: RelayBinarySocket,46+    invitation: PairingInvitation,47+    receiverDeps: AdoptionReceiverDeps,48+    confirm: suspend (sas: String) -> Boolean,49+): PairedDevice? {50+    socket.send(51+        JSONObject()52+            .put("type", "PEACH_HELLO")53+            .put("token", invitation.token)54+            .toString()55+            .toByteArray(Charsets.UTF_8),56+    )57+58+    val ack = JSONObject(String(socket.receive(), Charsets.UTF_8))59+    require(ack.optString("type") == "PEACH_HELLO_ACK") {60+        "expected PEACH_HELLO_ACK"61+    }62+    val sourceStatic = hexToPubkey(ack.optString("ephemeralPublicKeyHex"))63+    val noise = try {64+        noiseNKInitiator(65+            sourceStatic,66+            socket::send,67+            socket::receive,68+        )69+    } finally {70+        sourceStatic.fill(0)71+    }72+    val channel = object : PairingTransport {73+        override suspend fun send(msg: ByteArray) = socket.send(noise.encrypt(msg))74+        override suspend fun receive(): ByteArray = noise.decrypt(socket.receive())75+        override suspend fun close() = socket.close()76+    }77+    val pairState = AdoptionPairState(78+        rendezvousSecretHex = Rendezvous.bytesToHex(79+            Rendezvous.deriveSecret(noise.handshakeHash),80+        ),81+        relayUrl = invitation.relayUrl,82+    )83+    val boundDeps = object : AdoptionReceiverDeps {84+        override val deviceName = receiverDeps.deviceName85+        override val deviceType = receiverDeps.deviceType86+        override val pairState = pairState87+        override fun generateDeviceId() = receiverDeps.generateDeviceId()88+        override suspend fun confirmSas(): Boolean =89+            confirm(SASDerivation.derive(noise.handshakeHash))90+91+        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =92+            receiverDeps.createVaultFromRootKey(rootKeyHex)93+94+        override suspend fun deriveLocalIdentity(): LocalChirpIdentity =95+            receiverDeps.deriveLocalIdentity()96+97+        override suspend fun upsertPairedDevice(device: PairedDevice) {98+            receiverDeps.upsertPairedDevice(device)99+        }100+101+        override suspend fun applyLicense(license: String) {102+            receiverDeps.applyLicense(license)103+        }104+105+        override fun onError(error: String) {106+            receiverDeps.onError(error)107+        }108+    }109+    return requestAdoption(channel, boundDeps)110+}

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+23 −1
@@ -13,6 +13,7 @@1313 import androidx.compose.runtime.LaunchedEffect1414 import androidx.compose.runtime.getValue1515 import androidx.compose.ui.Modifier16+import androidx.compose.ui.platform.LocalContext1617 import androidx.hilt.navigation.compose.hiltViewModel1718 import androidx.lifecycle.compose.collectAsStateWithLifecycle1819 import androidx.navigation.NavHostController@@ -29,6 +30,9 @@2930 import com.peachpasswords.ui.lock.LockScreen3031 import com.peachpasswords.ui.onboarding.OnboardingScreen3132 import com.peachpasswords.ui.pairing.CodePairScreen33+import com.peachpasswords.ui.pairing.AdoptVaultScreen34+import com.peachpasswords.ui.onboarding.ONBOARD_KEY35+import com.peachpasswords.ui.onboarding.ONBOARD_PREFS3236 import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen3337 import com.peachpasswords.ui.settings.PanicScreen3438 import com.peachpasswords.ui.settings.SettingsScreen@@ -108,7 +112,8 @@108112                 val current = navController.currentDestination?.route109113                 if (current != null &&110114                     current != PeachNav.Lock.route &&111-                    current != PeachNav.Onboarding.route115+                    current != PeachNav.Onboarding.route &&116+                    current != PeachNav.AdoptVault.route112117                 ) {113118                     navController.navigate(PeachNav.Lock.route) {114119                         popUpTo(0) { inclusive = true }@@ -180,6 +185,7 @@180185                     navController.navigate(PeachNav.Lock.route)181186                 },182187                 onRestore = { nav(PeachNav.Import) },188+                onAdopt = { nav(PeachNav.AdoptVault) },183189             )184190         }185191         composable(PeachNav.Lock.route) {@@ -279,6 +285,22 @@279285             )280286         }281287         composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }288+        composable(PeachNav.AdoptVault.route) {289+            val context = LocalContext.current290+            AdoptVaultScreen(291+                onBack = back,292+                onComplete = {293+                    context294+                        .getSharedPreferences(ONBOARD_PREFS, android.content.Context.MODE_PRIVATE)295+                        .edit()296+                        .putBoolean(ONBOARD_KEY, true)297+                        .apply()298+                    navController.navigate(PeachNav.VaultList.route) {299+                        popUpTo(0) { inclusive = true }300+                    }301+                },302+            )303+        }282304         composable(PeachNav.Health.route) {283305             VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })284306         }

view file

app/src/main/java/com/peachpasswords/ui/PeachNav.kt+1 −0
@@ -57,6 +57,7 @@5757     data object CodexGenerate : PeachNav("codex/generate")5858     data object CodexRestore : PeachNav("codex/restore")5959     data object CodePair : PeachNav("pair/code")60+    data object AdoptVault : PeachNav("adopt-vault")6061     data object Health : PeachNav("health")6162     data object Trash : PeachNav("trash")6263     data object Import : PeachNav("import")

view file

app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+13 −11
@@ -113,11 +113,8 @@113113 @Composable114114 fun OnboardingScreen(115115     onFinished: () -> Unit,116-    // Card 3's "restore from backup" choice (Onboarding.tsx's onRestore prop).117-    // "I already have Peach" (onAdopt) isn't wired yet — AdoptVaultView's real118-    // semantics aren't confirmed on the Android side, so it isn't shown rather119-    // than shipping a button that does the wrong thing or nothing at all.120116     onRestore: () -> Unit = {},117+    onAdopt: () -> Unit = {},121118 ) {122119     val context = LocalContext.current123120     // rememberSaveable, not plain remember — navigating to "restore from@@ -167,7 +164,7 @@167164                     // The restore/adopt choice row only appears on the final168165                     // card (Onboarding.tsx's `card() === CARDS.length - 1`).169166                     if (active == cardCount - 1) {170-                        ChoiceRow(onRestore = onRestore)167+                        ChoiceRow(onRestore = onRestore, onAdopt = onAdopt)171168                     }172169                 }173170             }@@ -316,15 +313,20 @@316313 }317314 318315 /**319- * Card 3's choice row (Onboarding.tsx `peach-onboarding-choice-row`). The320- * browser also has an "I already have Peach" button here (onAdopt) — not321- * shown yet, see [OnboardingScreen]'s onRestore doc.316+ * Card 3's two existing-vault entry points.322317  */323318 @Composable324-private fun ChoiceRow(onRestore: () -> Unit) {319+private fun ChoiceRow(onRestore: () -> Unit, onAdopt: () -> Unit) {325320     Spacer(Modifier.height(14.dp))321+    ChoiceButton("I already have Peach", onAdopt)322+    Spacer(Modifier.height(8.dp))323+    ChoiceButton("restore from backup", onRestore)324+}325+326+@Composable327+private fun ChoiceButton(label: String, onClick: () -> Unit) {326328     Text(327-        "restore from backup",329+        label,328330         color = peachColors.T0,329331         style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),330332         modifier = Modifier@@ -335,7 +337,7 @@335337             .clickable(336338                 interactionSource = remember { MutableInteractionSource() },337339                 indication = null,338-                onClick = onRestore,340+                onClick = onClick,339341             )340342             .padding(vertical = 8.dp, horizontal = 10.dp),341343     )

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+249 −0
@@ -0,0 +1,249 @@1+package com.peachpasswords.ui.pairing2+3+import androidx.compose.foundation.clickable4+import androidx.compose.foundation.layout.Arrangement5+import androidx.compose.foundation.layout.Column6+import androidx.compose.foundation.layout.Spacer7+import androidx.compose.foundation.layout.fillMaxSize8+import androidx.compose.foundation.layout.fillMaxWidth9+import androidx.compose.foundation.layout.height10+import androidx.compose.foundation.layout.padding11+import androidx.compose.foundation.layout.systemBarsPadding12+import androidx.compose.foundation.rememberScrollState13+import androidx.compose.foundation.verticalScroll14+import androidx.compose.material3.OutlinedTextField15+import androidx.compose.material3.Text16+import androidx.compose.runtime.Composable17+import androidx.compose.runtime.getValue18+import androidx.compose.runtime.mutableStateOf19+import androidx.compose.runtime.remember20+import androidx.compose.runtime.setValue21+import androidx.compose.ui.Alignment22+import androidx.compose.ui.Modifier23+import androidx.compose.ui.text.input.PasswordVisualTransformation24+import androidx.compose.ui.text.input.VisualTransformation25+import androidx.compose.ui.text.style.TextAlign26+import androidx.compose.ui.unit.dp27+import androidx.compose.ui.unit.sp28+import androidx.hilt.navigation.compose.hiltViewModel29+import androidx.lifecycle.compose.collectAsStateWithLifecycle30+import com.peachpasswords.ui.security.StrengthBar31+import com.peachpasswords.ui.security.scorePassword32+import com.peachpasswords.ui.security.strengthLabel33+import com.peachpasswords.ui.theme.LocalPeachAccent34+import com.peachpasswords.ui.theme.PeachSpacing35+import com.peachpasswords.ui.theme.PeachType36+import com.peachpasswords.ui.theme.ShellBackground37+import com.peachpasswords.ui.theme.peachColors38+39+@Composable40+fun AdoptVaultScreen(41+    onBack: () -> Unit,42+    onComplete: () -> Unit,43+    vm: AdoptVaultViewModel = hiltViewModel(),44+) {45+    val state by vm.state.collectAsStateWithLifecycle()46+    var password by remember { mutableStateOf("") }47+    var confirmation by remember { mutableStateOf("") }48+    var reveal by remember { mutableStateOf(false) }49+    var invitation by remember { mutableStateOf("") }50+    val score = scorePassword(password)51+    val passwordReady =52+        password.length >= 12 && password == confirmation && score >= 253+54+    ShellBackground {55+        Column(56+            modifier = Modifier57+                .fillMaxSize()58+                .systemBarsPadding()59+                .verticalScroll(rememberScrollState())60+                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.lg),61+            horizontalAlignment = Alignment.CenterHorizontally,62+            verticalArrangement = Arrangement.Center,63+        ) {64+            Text(65+                "I already have Peach",66+                color = peachColors.T0,67+                fontFamily = PeachType.Display,68+                fontSize = 26.sp,69+            )70+            Spacer(Modifier.height(PeachSpacing.sm))71+72+            when (val current = state) {73+                AdoptVaultUiState.Password -> {74+                    Body(75+                        "Choose the password this phone will use. It never leaves this device; " +76+                            "your existing device will share the vault only after both screens confirm the same code.",77+                    )78+                    Spacer(Modifier.height(PeachSpacing.lg))79+                    OutlinedTextField(80+                        value = password,81+                        onValueChange = { password = it },82+                        label = { Text("new password") },83+                        singleLine = true,84+                        visualTransformation =85+                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),86+                        modifier = Modifier.fillMaxWidth(),87+                    )88+                    Spacer(Modifier.height(PeachSpacing.sm))89+                    OutlinedTextField(90+                        value = confirmation,91+                        onValueChange = { confirmation = it },92+                        label = { Text("confirm password") },93+                        singleLine = true,94+                        visualTransformation =95+                            if (reveal) VisualTransformation.None else PasswordVisualTransformation(),96+                        modifier = Modifier.fillMaxWidth(),97+                    )98+                    Text(99+                        if (reveal) "hide passwords" else "show passwords",100+                        color = LocalPeachAccent.current,101+                        fontFamily = PeachType.Mono,102+                        fontSize = 11.sp,103+                        modifier = Modifier104+                            .align(Alignment.End)105+                            .clickable { reveal = !reveal }106+                            .padding(PeachSpacing.sm),107+                    )108+                    StrengthBar(score, Modifier.fillMaxWidth())109+                    Spacer(Modifier.height(PeachSpacing.xs))110+                    Text(111+                        "strength: ${strengthLabel(score)} · at least 12 characters",112+                        color = peachColors.T2,113+                        fontFamily = PeachType.Mono,114+                        fontSize = 10.sp,115+                    )116+                    Spacer(Modifier.height(PeachSpacing.lg))117+                    PillButton(118+                        "Continue",119+                        LocalPeachAccent.current,120+                        filled = true,121+                    ) {122+                        if (passwordReady && vm.acceptPassword(password, confirmation)) {123+                            password = ""124+                            confirmation = ""125+                        }126+                    }127+                    if (!passwordReady) {128+                        Spacer(Modifier.height(PeachSpacing.xs))129+                        Text(130+                            "Continue unlocks when both passwords match and pass the strength check.",131+                            color = peachColors.T2,132+                            fontFamily = PeachType.Mono,133+                            fontSize = 10.sp,134+                            textAlign = TextAlign.Center,135+                        )136+                    }137+                }138+139+                AdoptVaultUiState.Pair -> {140+                    Body(141+                        "Connecting via secure relay — works anywhere. Scan the QR shown by " +142+                            "Peach on your existing device. It contains only the relay address and a single-use token.",143+                    )144+                    Spacer(Modifier.height(PeachSpacing.lg))145+                    PairingQrCamera(onInvitation = vm::adopt)146+                    Spacer(Modifier.height(PeachSpacing.lg))147+                    Text(148+                        "or enter the invitation manually",149+                        color = peachColors.T2,150+                        fontFamily = PeachType.Mono,151+                        fontSize = 10.sp,152+                    )153+                    OutlinedTextField(154+                        value = invitation,155+                        onValueChange = { invitation = it },156+                        label = { Text("peach-pair-v1:…") },157+                        minLines = 3,158+                        modifier = Modifier.fillMaxWidth(),159+                    )160+                    Spacer(Modifier.height(PeachSpacing.md))161+                    PillButton(162+                        "Connect",163+                        LocalPeachAccent.current,164+                        filled = true,165+                    ) { vm.adopt(invitation) }166+                }167+168+                AdoptVaultUiState.Connecting -> {169+                    Body("Opening the single-use relay room and establishing an encrypted session…")170+                }171+172+                is AdoptVaultUiState.ConfirmSas -> {173+                    Body(174+                        "Only continue if this code is identical on the device sharing the vault.",175+                    )176+                    Spacer(Modifier.height(PeachSpacing.lg))177+                    Text(178+                        current.sas,179+                        color = LocalPeachAccent.current,180+                        fontFamily = PeachType.Mono,181+                        fontSize = 38.sp,182+                        letterSpacing = 6.sp,183+                    )184+                    Spacer(Modifier.height(PeachSpacing.lg))185+                    PillButton(186+                        "They match — adopt vault",187+                        LocalPeachAccent.current,188+                        filled = true,189+                    ) { vm.confirmSas(true) }190+                    Spacer(Modifier.height(PeachSpacing.sm))191+                    PillButton("Cancel", peachColors.T2, filled = false) {192+                        vm.confirmSas(false)193+                    }194+                }195+196+                AdoptVaultUiState.Adopting -> {197+                    Body(198+                        "Both devices confirmed. Sealing the adopted vault under this phone's password…",199+                    )200+                }201+202+                is AdoptVaultUiState.Complete -> {203+                    Body("${current.sourceName} shared the vault successfully. Encrypted sync is now active.")204+                    Spacer(Modifier.height(PeachSpacing.lg))205+                    PillButton("Open vault", LocalPeachAccent.current, filled = true, onClick = onComplete)206+                }207+208+                is AdoptVaultUiState.Failed -> {209+                    Body(adoptionFailureCopy(current.reason))210+                    Spacer(Modifier.height(PeachSpacing.lg))211+                    PillButton("Start again", LocalPeachAccent.current, filled = true) {212+                        invitation = ""213+                        vm.restart()214+                    }215+                }216+            }217+218+            if (state !is AdoptVaultUiState.Adopting &&219+                state !is AdoptVaultUiState.Complete220+            ) {221+                Spacer(Modifier.height(PeachSpacing.md))222+                PillButton("Back", peachColors.T2, filled = false, onClick = onBack)223+            }224+        }225+    }226+}227+228+@Composable229+private fun Body(value: String) {230+    Text(231+        value,232+        color = peachColors.T2,233+        fontFamily = PeachType.Body,234+        fontSize = 13.sp,235+        textAlign = TextAlign.Center,236+        modifier = Modifier.fillMaxWidth(),237+    )238+}239+240+private fun adoptionFailureCopy(reason: String): String = when (reason) {241+    "invalid_pairing_invitation" ->242+        "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."243+    "ADOPT_SAS_REJECTED", "adoption_rejected" ->244+        "The confirmation was cancelled. No vault key was accepted."245+    "ADOPT_VAULT_ID_MISMATCH" ->246+        "The received vault did not pass its integrity check, so Peach refused it."247+    else ->248+        "The secure adoption session ended before it completed. Start again with a fresh invitation."249+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+181 −0
@@ -0,0 +1,181 @@1+package com.peachpasswords.ui.pairing2+3+import android.content.Context4+import androidx.lifecycle.ViewModel5+import androidx.lifecycle.viewModelScope6+import com.peachpasswords.license.LicenseRepository7+import com.peachpasswords.sync.AdoptionReceiverDeps8+import com.peachpasswords.sync.DeviceType9+import com.peachpasswords.sync.LocalChirpIdentity10+import com.peachpasswords.sync.PairedDevice11+import com.peachpasswords.sync.PairedDeviceStore12+import com.peachpasswords.sync.PeachSyncService13+import com.peachpasswords.sync.RelayAdoptionReceiver14+import com.peachpasswords.sync.buildLocalIdentity15+import com.peachpasswords.sync.getDeviceName16+import com.peachpasswords.sync.parsePairingInvitation17+import com.peachpasswords.ui.security.scorePassword18+import com.peachpasswords.vault.AdoptVaultResult19+import com.peachpasswords.vault.VaultCrypto20+import com.peachpasswords.vault.VaultRepository21+import dagger.hilt.android.lifecycle.HiltViewModel22+import dagger.hilt.android.qualifiers.ApplicationContext23+import java.util.UUID24+import javax.inject.Inject25+import kotlinx.coroutines.CompletableDeferred26+import kotlinx.coroutines.CancellationException27+import kotlinx.coroutines.Job28+import kotlinx.coroutines.flow.MutableStateFlow29+import kotlinx.coroutines.flow.StateFlow30+import kotlinx.coroutines.flow.asStateFlow31+import kotlinx.coroutines.launch32+33+sealed interface AdoptVaultUiState {34+    data object Password : AdoptVaultUiState35+    data object Pair : AdoptVaultUiState36+    data object Connecting : AdoptVaultUiState37+    data class ConfirmSas(val sas: String) : AdoptVaultUiState38+    data object Adopting : AdoptVaultUiState39+    data class Complete(val sourceName: String) : AdoptVaultUiState40+    data class Failed(val reason: String) : AdoptVaultUiState41+}42+43+@HiltViewModel44+class AdoptVaultViewModel @Inject constructor(45+    private val receiver: RelayAdoptionReceiver,46+    private val vault: VaultRepository,47+    private val devices: PairedDeviceStore,48+    private val license: LicenseRepository,49+    @ApplicationContext private val appContext: Context,50+) : ViewModel() {51+    private val _state = MutableStateFlow<AdoptVaultUiState>(AdoptVaultUiState.Password)52+    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()53+54+    private var password: String? = null55+    private var confirmation: CompletableDeferred<Boolean>? = null56+    private var adoptionJob: Job? = null57+    private var protocolError: String? = null58+59+    fun acceptPassword(value: String, repeated: String): Boolean {60+        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {61+            return false62+        }63+        password = value64+        _state.value = AdoptVaultUiState.Pair65+        return true66+    }67+68+    fun adopt(invitation: String) {69+        if (_state.value !is AdoptVaultUiState.Pair) return70+        if (parsePairingInvitation(invitation) == null) {71+            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")72+            clearPassword()73+            return74+        }75+        _state.value = AdoptVaultUiState.Connecting76+        protocolError = null77+        adoptionJob = viewModelScope.launch {78+            try {79+                val adopted = receiver.adopt(80+                    encodedInvitation = invitation,81+                    deps = receiverDeps(),82+                    confirm = { sas ->83+                        val pending = CompletableDeferred<Boolean>()84+                        confirmation = pending85+                        _state.value = AdoptVaultUiState.ConfirmSas(sas)86+                        pending.await()87+                    },88+                )89+                _state.value = AdoptVaultUiState.Complete(90+                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",91+                )92+            } catch (error: CancellationException) {93+                throw error94+            } catch (error: Throwable) {95+                _state.value = AdoptVaultUiState.Failed(96+                    protocolError ?: error.message ?: "adoption_failed",97+                )98+            } finally {99+                confirmation = null100+                clearPassword()101+            }102+        }103+    }104+105+    fun confirmSas(matches: Boolean) {106+        val pending = confirmation ?: return107+        confirmation = null108+        if (matches) _state.value = AdoptVaultUiState.Adopting109+        pending.complete(matches)110+    }111+112+    fun restart() {113+        adoptionJob?.cancel()114+        adoptionJob = null115+        confirmation?.complete(false)116+        confirmation = null117+        clearPassword()118+        _state.value = AdoptVaultUiState.Password119+    }120+121+    private fun receiverDeps(): AdoptionReceiverDeps =122+        object : AdoptionReceiverDeps {123+            override val deviceName = getDeviceName(appContext)124+            override val deviceType = DeviceType.ANDROID125+            override fun generateDeviceId(): String = UUID.randomUUID().toString()126+127+            // RelayAdoptionReceiver replaces this with the SAS bound to its128+            // completed Noise transcript.129+            override suspend fun confirmSas(): Boolean = false130+131+            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {132+                val localPassword = password133+                    ?: throw IllegalStateException("adoption_password_unavailable")134+                return when (135+                    val result = vault.createVaultFromRootKey(localPassword, rootKeyHex)136+                ) {137+                    is AdoptVaultResult.Success -> result.vaultId138+                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)139+                }140+            }141+142+            override suspend fun deriveLocalIdentity(): LocalChirpIdentity {143+                val master = vault.syncMasterKey()144+                    ?: throw IllegalStateException("adopted_vault_unavailable")145+                return try {146+                    buildLocalIdentity(appContext, master)147+                } finally {148+                    VaultCrypto.zero(master)149+                }150+            }151+152+            override suspend fun upsertPairedDevice(device: PairedDevice) {153+                devices.upsert(device)154+                // Start listening only after the source identity and relay-bound155+                // pair state are durable. This lets the browser source perform156+                // its first sync immediately after adoption.157+                PeachSyncService.start(appContext)158+            }159+160+            override suspend fun applyLicense(licenseKey: String) {161+                if (license.validateLicense(licenseKey)) {162+                    license.storeLicense(licenseKey)163+                }164+            }165+166+            override fun onError(error: String) {167+                protocolError = error168+            }169+        }170+171+    private fun clearPassword() {172+        password = null173+    }174+175+    override fun onCleared() {176+        adoptionJob?.cancel()177+        confirmation?.complete(false)178+        clearPassword()179+        super.onCleared()180+    }181+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/PairingQrCamera.kt+120 −0
@@ -0,0 +1,120 @@1+package com.peachpasswords.ui.pairing2+3+import android.Manifest4+import android.content.pm.PackageManager5+import androidx.activity.compose.rememberLauncherForActivityResult6+import androidx.activity.result.contract.ActivityResultContracts7+import androidx.camera.core.CameraSelector8+import androidx.camera.core.ImageAnalysis9+import androidx.camera.core.Preview10+import androidx.camera.lifecycle.ProcessCameraProvider11+import androidx.camera.view.PreviewView12+import androidx.compose.foundation.background13+import androidx.compose.foundation.layout.Box14+import androidx.compose.foundation.layout.fillMaxSize15+import androidx.compose.foundation.layout.fillMaxWidth16+import androidx.compose.foundation.layout.height17+import androidx.compose.foundation.shape.RoundedCornerShape18+import androidx.compose.material3.Text19+import androidx.compose.runtime.Composable20+import androidx.compose.runtime.DisposableEffect21+import androidx.compose.runtime.LaunchedEffect22+import androidx.compose.runtime.getValue23+import androidx.compose.runtime.mutableStateOf24+import androidx.compose.runtime.remember25+import androidx.compose.runtime.setValue26+import androidx.compose.ui.Alignment27+import androidx.compose.ui.Modifier28+import androidx.compose.ui.draw.clip29+import androidx.compose.ui.platform.LocalContext30+import androidx.compose.ui.unit.dp31+import androidx.compose.ui.unit.sp32+import androidx.compose.ui.viewinterop.AndroidView33+import androidx.core.content.ContextCompat34+import androidx.lifecycle.compose.LocalLifecycleOwner35+import com.peachpasswords.ui.theme.PeachType36+import com.peachpasswords.ui.theme.peachColors37+import java.util.concurrent.Executors38+39+@Composable40+internal fun PairingQrCamera(41+    onInvitation: (String) -> Unit,42+    modifier: Modifier = Modifier,43+) {44+    val context = LocalContext.current45+    var granted by remember {46+        mutableStateOf(47+            ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==48+                PackageManager.PERMISSION_GRANTED,49+        )50+    }51+    val permission = rememberLauncherForActivityResult(52+        ActivityResultContracts.RequestPermission(),53+    ) { granted = it }54+    LaunchedEffect(Unit) {55+        if (!granted) permission.launch(Manifest.permission.CAMERA)56+    }57+58+    if (!granted) {59+        Box(60+            modifier61+                .fillMaxWidth()62+                .height(250.dp)63+                .clip(RoundedCornerShape(16.dp))64+                .background(peachColors.SurfaceElevated),65+            contentAlignment = Alignment.Center,66+        ) {67+            Text(68+                "Camera access is needed to scan.\nUse the manual invitation below instead.",69+                color = peachColors.T2,70+                fontFamily = PeachType.Mono,71+                fontSize = 11.sp,72+            )73+        }74+        return75+    }76+77+    val lifecycleOwner = LocalLifecycleOwner.current78+    val executor = remember { Executors.newSingleThreadExecutor() }79+    DisposableEffect(Unit) {80+        onDispose { executor.shutdown() }81+    }82+    AndroidView(83+        modifier = modifier84+            .fillMaxWidth()85+            .height(250.dp)86+            .clip(RoundedCornerShape(16.dp)),87+        factory = { viewContext ->88+            PreviewView(viewContext).apply {89+                scaleType = PreviewView.ScaleType.FILL_CENTER90+                val previewView = this91+                val provider = ProcessCameraProvider.getInstance(viewContext)92+                provider.addListener({93+                    val cameraProvider = provider.get()94+                    val preview = Preview.Builder().build().also {95+                        it.setSurfaceProvider(previewView.surfaceProvider)96+                    }97+                    val analysis = ImageAnalysis.Builder()98+                        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)99+                        .build()100+                    analysis.setAnalyzer(executor) { proxy ->101+                        try {102+                            PairingQrFrame.decode(proxy)?.let(onInvitation)103+                        } finally {104+                            proxy.close()105+                        }106+                    }107+                    runCatching {108+                        cameraProvider.unbindAll()109+                        cameraProvider.bindToLifecycle(110+                            lifecycleOwner,111+                            CameraSelector.DEFAULT_BACK_CAMERA,112+                            preview,113+                            analysis,114+                        )115+                    }116+                }, ContextCompat.getMainExecutor(viewContext))117+            }118+        },119+    )120+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/PairingQrFrame.kt+63 −0
@@ -0,0 +1,63 @@1+package com.peachpasswords.ui.pairing2+3+import androidx.camera.core.ImageProxy4+import com.google.zxing.BinaryBitmap5+import com.google.zxing.DecodeHintType6+import com.google.zxing.PlanarYUVLuminanceSource7+import com.google.zxing.common.GlobalHistogramBinarizer8+import com.google.zxing.common.HybridBinarizer9+import com.google.zxing.qrcode.QRCodeReader10+11+/** QR-only CameraX decoder for reachability invitations. */12+internal object PairingQrFrame {13+    fun decode(image: ImageProxy): String? {14+        val plane = image.planes.getOrNull(0) ?: return null15+        val buffer = plane.buffer16+        val data = ByteArray(buffer.remaining())17+        buffer.get(data)18+        val width = image.width19+        val height = image.height20+        val luma = if (plane.rowStride == width) {21+            data22+        } else {23+            ByteArray(width * height).also { packed ->24+                for (row in 0 until height) {25+                    System.arraycopy(26+                        data,27+                        row * plane.rowStride,28+                        packed,29+                        row * width,30+                        width,31+                    )32+                }33+            }34+        }35+        val source = PlanarYUVLuminanceSource(36+            luma,37+            width,38+            height,39+            0,40+            0,41+            width,42+            height,43+            false,44+        )45+        val hints = mapOf(46+            DecodeHintType.TRY_HARDER to true,47+            DecodeHintType.POSSIBLE_FORMATS to listOf(com.google.zxing.BarcodeFormat.QR_CODE),48+        )49+        val attempts = listOf(50+            BinaryBitmap(HybridBinarizer(source)),51+            BinaryBitmap(GlobalHistogramBinarizer(source)),52+            BinaryBitmap(HybridBinarizer(source.rotateCounterClockwise())),53+        )54+        for (candidate in attempts) {55+            try {56+                return QRCodeReader().decode(candidate, hints).text57+            } catch (_: Exception) {58+                // Try the next lighting/orientation strategy.59+            }60+        }61+        return null62+    }63+}

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+1 −0
@@ -212,6 +212,7 @@212212 213213     suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {214214         if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")215+        if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")215216         if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {216217             return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")217218         }

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+167 −0
@@ -9,6 +9,7 @@99 import org.junit.Assert.assertFalse1010 import org.junit.Assert.assertNotNull1111 import org.junit.Assert.assertNull12+import org.junit.Assert.assertTrue1213 import org.junit.Test1314 1415 class RelayAdoptionHostTest {@@ -164,6 +165,172 @@164165 165166         assertNull(sourceJob.await())166167         assertFalse(rootRequested.get())168+    }169+170+    @Test fun `receiver session completes real Noise adoption with matching pair state`() = runBlocking {171+        val (sourceSocket, receiverSocket) = socketPair()172+        val token = "AAECAwQFBgcICQoLDA0ODw"173+        val relay = "wss://relay.example.test"174+        val root = ByteArray(32) { (it + 3).toByte() }175+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")176+        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")177+        val sourceIdentity = identity("source", sourceKeys.publicKey)178+        val receiverIdentity = identity("receiver", receiverKeys.publicKey)179+        var sourceStored: PairedDevice? = null180+        var receiverStored: PairedDevice? = null181+        var sourceSas = ""182+        var receiverSas = ""183+184+        val sourceJob = async {185+            runRelayAdoptionSourceSession(186+                sourceSocket,187+                token,188+                relay,189+                sourceKeys.secretKey,190+                sourceKeys.publicKey,191+                sourceIdentity,192+                object : AdoptionSourceDeps {193+                    override val sourceIdentity = sourceIdentity194+                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)195+                    override suspend fun vaultId() = computeVaultId(root)196+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true197+                    override suspend fun upsertPairedDevice(device: PairedDevice) {198+                        sourceStored = device199+                    }200+                },201+                confirm = { sas, _ ->202+                    sourceSas = sas203+                    true204+                },205+            )206+        }207+208+        val received = runRelayAdoptionReceiverSession(209+            receiverSocket,210+            PairingInvitation(relay, token),211+            object : AdoptionReceiverDeps {212+                override val deviceName = "receiver"213+                override val deviceType = DeviceType.ANDROID214+                override fun generateDeviceId() = "session-only-request"215+                override suspend fun confirmSas() = error("session wrapper owns SAS")216+                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {217+                    assertEquals(Rendezvous.bytesToHex(root), rootKeyHex)218+                    return computeVaultId(hexToPubkey(rootKeyHex))219+                }220+                override suspend fun deriveLocalIdentity() = receiverIdentity221+                override suspend fun upsertPairedDevice(device: PairedDevice) {222+                    receiverStored = device223+                }224+                override fun onError(error: String) = throw AssertionError(error)225+            },226+            confirm = { sas ->227+                receiverSas = sas228+                true229+            },230+        )231+232+        assertNotNull(received)233+        assertNotNull(sourceJob.await())234+        assertEquals(sourceSas, receiverSas)235+        assertEquals(relay, sourceStored?.relayUrl)236+        assertEquals(relay, receiverStored?.relayUrl)237+        assertEquals(sourceStored?.rendezvousSecretHex, receiverStored?.rendezvousSecretHex)238+    }239+240+    @Test fun `receiver rejection is encrypted and source never reads root`() = runBlocking {241+        val (sourceSocket, receiverSocket) = socketPair()242+        val token = "AAECAwQFBgcICQoLDA0ODw"243+        val relay = "wss://relay.example.test"244+        val root = ByteArray(32) { (it + 9).toByte() }245+        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")246+        val rootRequested = AtomicBoolean(false)247+248+        val sourceJob = async {249+            runRelayAdoptionSourceSession(250+                sourceSocket,251+                token,252+                relay,253+                sourceKeys.secretKey,254+                sourceKeys.publicKey,255+                identity("source", sourceKeys.publicKey),256+                object : AdoptionSourceDeps {257+                    override val sourceIdentity = identity("source", sourceKeys.publicKey)258+                    override suspend fun rootKeyHex(): String {259+                        rootRequested.set(true)260+                        return Rendezvous.bytesToHex(root)261+                    }262+                    override suspend fun vaultId() = computeVaultId(root)263+                    override suspend fun onConsent(request: AdoptionFrame.Request) = true264+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit265+                },266+                confirm = { _, _ -> true },267+            )268+        }269+        var created = false270+        val received = runRelayAdoptionReceiverSession(271+            receiverSocket,272+            PairingInvitation(relay, token),273+            object : AdoptionReceiverDeps {274+                override val deviceName = "receiver"275+                override val deviceType = DeviceType.ANDROID276+                override fun generateDeviceId() = "session-only-request"277+                override suspend fun confirmSas() = error("session wrapper owns SAS")278+                override suspend fun createVaultFromRootKey(rootKeyHex: String): String {279+                    created = true280+                    return computeVaultId(hexToPubkey(rootKeyHex))281+                }282+                override suspend fun deriveLocalIdentity() =283+                    identity("receiver", sourceKeys.publicKey)284+                override suspend fun upsertPairedDevice(device: PairedDevice) = Unit285+                override fun onError(error: String) {286+                    assertEquals("ADOPT_SAS_REJECTED", error)287+                }288+            },289+            confirm = { false },290+        )291+292+        assertNull(received)293+        assertNull(sourceJob.await())294+        assertFalse(rootRequested.get())295+        assertFalse(created)296+    }297+298+    @Test fun `malformed receiver ACK fails before any vault mutation`() = runBlocking {299+        val (unusedSource, receiverSocket) = socketPair()300+        unusedSource.send(301+            JSONObject()302+                .put("type", "PEACH_HELLO_ACK")303+                .put("ephemeralPublicKeyHex", "ab")304+                .toString()305+                .toByteArray(),306+        )307+        var created = false308+        val result = runCatching {309+            runRelayAdoptionReceiverSession(310+                receiverSocket,311+                PairingInvitation(312+                    "wss://relay.example.test",313+                    "AAECAwQFBgcICQoLDA0ODw",314+                ),315+                object : AdoptionReceiverDeps {316+                    override val deviceName = "receiver"317+                    override val deviceType = DeviceType.ANDROID318+                    override fun generateDeviceId() = "request"319+                    override suspend fun confirmSas() = false320+                    override suspend fun createVaultFromRootKey(rootKeyHex: String): String {321+                        created = true322+                        return ""323+                    }324+                    override suspend fun deriveLocalIdentity() =325+                        error("must not derive identity")326+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit327+                    override fun onError(error: String) = Unit328+                },329+                confirm = { true },330+            )331+        }332+        assertTrue(result.isFailure)333+        assertFalse(created)167334     }168335 169336     private fun encryptedChannel(

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+17 −0
@@ -189,6 +189,23 @@189189         assertFalse(r.hasVault())190190     }191191 192+    @Test fun adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking {193+        val r = repo()194+        r.addEntry(login("Must survive", password = "local-only"))195+        val originalVaultId = r.syncVaultId()196+197+        assertEquals(198+            AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"),199+            r.createVaultFromRootKey(200+                "receiver-local-passphrase-456",201+                "55".repeat(32),202+            ),203+        )204+205+        assertEquals(originalVaultId, r.syncVaultId())206+        assertTrue(r.getEntries().any { it.title == "Must survive" })207+    }208+192209     @Test fun create_then_destroy_then_create_fresh() = runBlocking {193210         val storage = Storage()194211         val r = VaultRepository(storage)

view file