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
↓ peach-android-5e73e5d3.tar.gzatlas at this save
files changed12 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 −0app/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 {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+}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 }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")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 )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+}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+}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+}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+}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 }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(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)