1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import androidx.lifecycle.ViewModel6import androidx.lifecycle.viewModelScope7import com.peachpasswords.license.LicenseRepository8import com.peachpasswords.sync.AdoptionReceiverDeps9import com.peachpasswords.sync.AdoptionSourceDeps10import com.peachpasswords.sync.BackgroundSyncPreference11import com.peachpasswords.sync.DeviceType12import com.peachpasswords.sync.AdoptionFrame13import com.peachpasswords.sync.LocalDeviceIdentity14import com.peachpasswords.sync.PairedDevice15import com.peachpasswords.sync.PairedDeviceStore16import com.peachpasswords.sync.PendingVouchedDeviceStore17import com.peachpasswords.sync.SignedDeviceManifest18import com.peachpasswords.sync.VaultSyncAdapter19import com.peachpasswords.sync.PeachSyncService20import com.peachpasswords.sync.RelayAdoptionReceiver21import com.peachpasswords.sync.RelaySettingsStore22import com.peachpasswords.sync.RelayDecision23import com.peachpasswords.sync.VaultStatus24import com.peachpasswords.sync.buildLocalIdentity25import com.peachpasswords.sync.computeVaultId26import com.peachpasswords.sync.deriveLocalSyncKeypair27import com.peachpasswords.sync.getDeviceName28import com.peachpasswords.sync.getOrCreateDeviceId29import com.peachpasswords.sync.hexToPubkey30import com.peachpasswords.sync.parsePairingInvitation31import com.peachpasswords.ui.security.scorePassword32import com.peachpasswords.vault.AdoptVaultResult33import com.peachpasswords.vault.VaultCrypto34import com.peachpasswords.vault.VaultRepository35import dagger.hilt.android.lifecycle.HiltViewModel36import dagger.hilt.android.qualifiers.ApplicationContext37import java.util.UUID38import javax.inject.Inject39import kotlinx.coroutines.CompletableDeferred40import kotlinx.coroutines.CancellationException41import kotlinx.coroutines.Job42import kotlinx.coroutines.flow.MutableStateFlow43import kotlinx.coroutines.flow.StateFlow44import kotlinx.coroutines.flow.asStateFlow45import kotlinx.coroutines.launch4647sealed interface AdoptVaultUiState {48 data object Password : AdoptVaultUiState49 data object Pair : AdoptVaultUiState50 data object Connecting : AdoptVaultUiState51 5253545556 data class ConfirmSas(57 val sas: String,58 val isSource: Boolean = false,59 val peerName: String? = null,60 ) : AdoptVaultUiState61 data object Adopting : AdoptVaultUiState62 data class Complete(val sourceName: String) : AdoptVaultUiState63 data class Failed(val reason: String) : AdoptVaultUiState64}6566@HiltViewModel67class AdoptVaultViewModel @Inject constructor(68 private val receiver: RelayAdoptionReceiver,69 private val vault: VaultRepository,70 private val devices: PairedDeviceStore,71 private val relaySettings: RelaySettingsStore,72 private val backgroundSync: BackgroundSyncPreference,73 private val license: LicenseRepository,74 private val pendingVouchedStore: PendingVouchedDeviceStore,75 @ApplicationContext private val appContext: Context,76) : ViewModel() {77 7879808182 val joining: Boolean = vault.hasVault()8384 private val _state = MutableStateFlow<AdoptVaultUiState>(85 initialAdoptVaultState(joining),86 )87 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8889 private var password: String? = null90 private var confirmation: CompletableDeferred<Boolean>? = null91 private var adoptionJob: Job? = null92 private var protocolError: String? = null9394 fun acceptPassword(value: String, repeated: String): Boolean {95 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {96 return false97 }98 password = value99 _state.value = AdoptVaultUiState.Pair100 return true101 }102103 fun adopt(invitation: String) {104 if (_state.value !is AdoptVaultUiState.Pair) return105 val parsed = parsePairingInvitation(invitation)106 if (parsed == null) {107 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")108 clearPassword()109 return110 }111 when (parsed.vaultStatus) {112 VaultStatus.POPULATED -> startReceiverFlow(invitation)113 VaultStatus.EMPTY -> startSourceFlow(invitation)114 }115 }116117 private fun startReceiverFlow(invitation: String) {118 _state.value = AdoptVaultUiState.Connecting119 protocolError = null120 adoptionJob = viewModelScope.launch {121 try {122 val adopted = receiver.adopt(123 encodedInvitation = invitation,124 deps = receiverDeps(),125 confirm = { sas ->126 val pending = CompletableDeferred<Boolean>()127 confirmation = pending128 _state.value = AdoptVaultUiState.ConfirmSas(129 sas = sas,130 isSource = false,131 )132 pending.await()133 },134 )135 136 137 138 139 140 adopted.relayUrl?.let(relaySettings::setRelayUrl)141 _state.value = AdoptVaultUiState.Complete(142 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",143 )144 } catch (error: CancellationException) {145 throw error146 } catch (error: Throwable) {147 _state.value = AdoptVaultUiState.Failed(148 protocolError ?: error.message ?: "adoption_failed",149 )150 } finally {151 confirmation = null152 clearPassword()153 }154 }155 }156157 158159160161162163164 private fun startSourceFlow(invitation: String) {165 val master = vault.syncMasterKey()166 if (master == null) {167 _state.value = AdoptVaultUiState.Failed("vault_locked")168 clearPassword()169 return170 }171 when (val decision = relaySettings.relayInForce()) {172 is RelayDecision.Refuse -> {173 VaultCrypto.zero(master)174 _state.value = AdoptVaultUiState.Failed(decision.reason.wire())175 clearPassword()176 return177 }178 is RelayDecision.Dial -> Unit179 }180 _state.value = AdoptVaultUiState.Connecting181 protocolError = null182183 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))184 val identity = buildLocalIdentity(appContext, master)185 VaultCrypto.zero(master)186 adoptionJob = viewModelScope.launch {187 try {188 val paired = receiver.grantFromInvitation(189 encodedInvitation = invitation,190 sourceDeps = sourceDeps(identity),191 confirm = { sas, request ->192 val pending = CompletableDeferred<Boolean>()193 confirmation = pending194 _state.value = AdoptVaultUiState.ConfirmSas(195 sas = sas,196 isSource = true,197 peerName = request.deviceName.ifBlank { "New device" },198 )199 pending.await()200 },201 )202 paired.relayUrl?.let(relaySettings::setRelayUrl)203 _state.value = AdoptVaultUiState.Complete(204 paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",205 )206 } catch (error: CancellationException) {207 throw error208 } catch (error: Throwable) {209 _state.value = AdoptVaultUiState.Failed(210 protocolError ?: error.message ?: "adoption_failed",211 )212 } finally {213 VaultCrypto.zero(keys.secretKey)214 VaultCrypto.zero(keys.publicKey)215 confirmation = null216 clearPassword()217 }218 }219 }220221 fun confirmSas(matches: Boolean) {222 val pending = confirmation ?: return223 confirmation = null224 if (matches) _state.value = AdoptVaultUiState.Adopting225 pending.complete(matches)226 }227228 fun restart() {229 adoptionJob?.cancel()230 adoptionJob = null231 confirmation?.complete(false)232 confirmation = null233 clearPassword()234 _state.value = initialAdoptVaultState(joining)235 }236237 private fun receiverDeps(): AdoptionReceiverDeps =238 object : AdoptionReceiverDeps {239 override val deviceName = getDeviceName(appContext)240 override val deviceType = DeviceType.ANDROID241 override fun generateDeviceId(): String = UUID.randomUUID().toString()242243 244 245 override suspend fun confirmSas(): Boolean = false246247 override suspend fun validateSource(source: AdoptionFrame.Source) {248 249 250 251 252 253 254 if (!joining) return255 }256257 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {258 259 260 261 val result = if (joining) {262 val incomingRoot = hexToPubkey(rootKeyHex)263 val incomingVaultId = try {264 computeVaultId(incomingRoot)265 } finally {266 VaultCrypto.zero(incomingRoot)267 }268 AdoptVaultResult.Success(269 requireSameVaultForPopulatedRePair(270 localVaultId = vault.syncVaultId(),271 incomingVaultId = incomingVaultId,272 ),273 )274 } else {275 val localPassword = password276 ?: throw IllegalStateException("adoption_password_unavailable")277 vault.createVaultFromRootKey(localPassword, rootKeyHex)278 }279 return when (result) {280 is AdoptVaultResult.Success -> result.vaultId281 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)282 }283 }284285 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {286 val master = vault.syncMasterKey()287 ?: throw IllegalStateException("adopted_vault_unavailable")288 return try {289 buildLocalIdentity(appContext, master)290 } finally {291 VaultCrypto.zero(master)292 }293 }294295 override suspend fun clearPairedDeviceRevocation(deviceId: String) {296 devices.clearRevocation(deviceId)297 }298299 override suspend fun upsertPairedDevice(device: PairedDevice) {300 devices.upsert(device)301 }302303 override suspend fun applyLicense(licenseKey: String) {304 if (!license.validateLicense(licenseKey)) {305 throw IllegalStateException("license_transfer_failed")306 }307 license.storeLicense(licenseKey)308 if (!license.isProActive()) {309 throw IllegalStateException("license_transfer_failed")310 }311 }312313 override suspend fun license(): String? =314 license.getLicenseKey()?.takeIf(license::validateLicense)315316 override fun onError(error: String) {317 protocolError = error318 }319320 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {321 val adapter = VaultSyncAdapter(322 repo = vault,323 ourDeviceId = getOrCreateDeviceId(appContext),324 pairedDevices = devices,325 )326 adapter.applyEntries(entries)327 adapter.applyTombstones(tombstones)328 if (!duressSlot.isNullOrBlank()) {329 try {330 val ok = vault.installAdoptedDuressSlot(duressSlot)331 if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")332 } catch (e: Exception) {333 Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)334 }335 }336 }337338 override suspend fun applyIncomingDeviceManifest(339 manifest: SignedDeviceManifest,340 authenticatedRemoteStaticPublicKey: ByteArray,341 ) {342 343 344 345 346 347 348 val adapter = VaultSyncAdapter(349 repo = vault,350 ourDeviceId = getOrCreateDeviceId(appContext),351 pairedDevices = devices,352 pendingVouched = pendingVouchedStore,353 )354 adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)355 }356357 override suspend fun markSynced(deviceId: String) {358 devices.updateLastSync(deviceId)359 }360 }361362 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =363 object : AdoptionSourceDeps {364 override val sourceIdentity: LocalDeviceIdentity = identity365366 override suspend fun rootKeyHex(): String {367 val root = vault.syncMasterKey()368 ?: throw IllegalStateException("vault_locked")369 return try {370 root.joinToString("") { "%02x".format(it) }371 } finally {372 VaultCrypto.zero(root)373 }374 }375376 override suspend fun vaultId(): String =377 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")378379 override suspend fun license(): String? =380 license.getLicenseKey()?.takeIf(license::validateLicense)381382 override suspend fun applyLicense(licenseKey: String) {383 if (!license.validateLicense(licenseKey)) {384 throw IllegalStateException("license_transfer_failed")385 }386 license.storeLicense(licenseKey)387 if (!license.isProActive()) {388 throw IllegalStateException("license_transfer_failed")389 }390 }391392 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true393394 override suspend fun upsertPairedDevice(device: PairedDevice) {395 devices.clearRevocation(device.deviceId)396 devices.upsert(device)397 }398399 override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {400 val adapter = VaultSyncAdapter(401 repo = vault,402 ourDeviceId = getOrCreateDeviceId(appContext),403 pairedDevices = devices,404 )405 val entryManifest = adapter.buildEntryManifest()406 val entryIds = entryManifest.keys.toList()407 val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()408 val tombstones = adapter.buildTombstoneManifest()409 return entries to tombstones410 }411412 override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {413 val adapter = VaultSyncAdapter(414 repo = vault,415 ourDeviceId = getOrCreateDeviceId(appContext),416 pairedDevices = devices,417 )418 return adapter.buildOutgoingDeviceManifest()419 }420421 override suspend fun markSynced(deviceId: String) {422 devices.updateLastSync(deviceId)423 }424425 override suspend fun duressSlotJson(): String? =426 vault.exportDuressSlotForAdoption()427 }428429 fun setBackgroundSync(enabled: Boolean) {430 backgroundSync.setEnabled(enabled)431 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)432 }433434 private fun clearPassword() {435 password = null436 }437438 override fun onCleared() {439 adoptionJob?.cancel()440 confirmation?.complete(false)441 clearPassword()442 super.onCleared()443 }444}445446internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =447 if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password448449450451452453454internal fun requireSameVaultForPopulatedRePair(455 localVaultId: String?,456 incomingVaultId: String,457): String {458 if (localVaultId == null) throw IllegalStateException("vault_locked")459 if (localVaultId != incomingVaultId) {460 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")461 }462 return localVaultId463}