1package com.peachpasswords.ui.pairing23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.license.LicenseRepository7import com.peachpasswords.sync.AdoptionReceiverDeps8import com.peachpasswords.sync.AdoptionSourceDeps9import com.peachpasswords.sync.BackgroundSyncPreference10import com.peachpasswords.sync.DeviceType11import com.peachpasswords.sync.AdoptionFrame12import com.peachpasswords.sync.LocalDeviceIdentity13import com.peachpasswords.sync.PairedDevice14import com.peachpasswords.sync.PairedDeviceStore15import com.peachpasswords.sync.PeachSyncService16import com.peachpasswords.sync.RelayAdoptionReceiver17import com.peachpasswords.sync.RelaySettingsStore18import com.peachpasswords.sync.RelayDecision19import com.peachpasswords.sync.VaultStatus20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.computeVaultId22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getDeviceName24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.sync.hexToPubkey26import com.peachpasswords.sync.parsePairingInvitation27import com.peachpasswords.ui.security.scorePassword28import com.peachpasswords.vault.AdoptVaultResult29import com.peachpasswords.vault.VaultCrypto30import com.peachpasswords.vault.VaultRepository31import dagger.hilt.android.lifecycle.HiltViewModel32import dagger.hilt.android.qualifiers.ApplicationContext33import java.util.UUID34import javax.inject.Inject35import kotlinx.coroutines.CompletableDeferred36import kotlinx.coroutines.CancellationException37import kotlinx.coroutines.Job38import kotlinx.coroutines.flow.MutableStateFlow39import kotlinx.coroutines.flow.StateFlow40import kotlinx.coroutines.flow.asStateFlow41import kotlinx.coroutines.launch4243sealed interface AdoptVaultUiState {44 data object Password : AdoptVaultUiState45 4647484950 data object Disclosure : AdoptVaultUiState51 data object Pair : AdoptVaultUiState52 data object Connecting : AdoptVaultUiState53 5455565758 data class ConfirmSas(59 val sas: String,60 val isSource: Boolean = false,61 val peerName: String? = null,62 ) : AdoptVaultUiState63 data object Adopting : AdoptVaultUiState64 data class Complete(val sourceName: String) : AdoptVaultUiState65 data class Failed(val reason: String) : AdoptVaultUiState66}6768@HiltViewModel69class AdoptVaultViewModel @Inject constructor(70 private val receiver: RelayAdoptionReceiver,71 private val vault: VaultRepository,72 private val devices: PairedDeviceStore,73 private val relaySettings: RelaySettingsStore,74 private val backgroundSync: BackgroundSyncPreference,75 private val license: LicenseRepository,76 @ApplicationContext private val appContext: Context,77) : ViewModel() {78 7980818283 val joining: Boolean = vault.hasVault()8485 private val _state = MutableStateFlow<AdoptVaultUiState>(86 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,87 )88 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8990 private var password: String? = null91 private var confirmation: CompletableDeferred<Boolean>? = null92 private var adoptionJob: Job? = null93 private var protocolError: String? = null9495 fun acceptPassword(value: String, repeated: String): Boolean {96 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {97 return false98 }99 password = value100 _state.value = AdoptVaultUiState.Pair101 return true102 }103104 105 fun acceptDisclosure() {106 if (_state.value is AdoptVaultUiState.Disclosure) {107 _state.value = AdoptVaultUiState.Pair108 }109 }110111 fun adopt(invitation: String) {112 if (_state.value !is AdoptVaultUiState.Pair) return113 val parsed = parsePairingInvitation(invitation)114 if (parsed == null) {115 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")116 clearPassword()117 return118 }119 when (parsed.vaultStatus) {120 VaultStatus.POPULATED -> startReceiverFlow(invitation)121 VaultStatus.EMPTY -> startSourceFlow(invitation)122 }123 }124125 private fun startReceiverFlow(invitation: String) {126 _state.value = AdoptVaultUiState.Connecting127 protocolError = null128 adoptionJob = viewModelScope.launch {129 try {130 val adopted = receiver.adopt(131 encodedInvitation = invitation,132 deps = receiverDeps(),133 confirm = { sas ->134 val pending = CompletableDeferred<Boolean>()135 confirmation = pending136 _state.value = AdoptVaultUiState.ConfirmSas(137 sas = sas,138 isSource = false,139 )140 pending.await()141 },142 )143 144 145 146 147 148 adopted.relayUrl?.let(relaySettings::setRelayUrl)149 _state.value = AdoptVaultUiState.Complete(150 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",151 )152 } catch (error: CancellationException) {153 throw error154 } catch (error: Throwable) {155 _state.value = AdoptVaultUiState.Failed(156 protocolError ?: error.message ?: "adoption_failed",157 )158 } finally {159 confirmation = null160 clearPassword()161 }162 }163 }164165 166167168169170171172 private fun startSourceFlow(invitation: String) {173 val master = vault.syncMasterKey()174 if (master == null) {175 _state.value = AdoptVaultUiState.Failed("vault_locked")176 clearPassword()177 return178 }179 when (val decision = relaySettings.relayInForce()) {180 is RelayDecision.Refuse -> {181 VaultCrypto.zero(master)182 _state.value = AdoptVaultUiState.Failed(decision.reason.wire())183 clearPassword()184 return185 }186 is RelayDecision.Dial -> Unit187 }188 _state.value = AdoptVaultUiState.Connecting189 protocolError = null190191 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))192 val identity = buildLocalIdentity(appContext, master)193 VaultCrypto.zero(master)194 adoptionJob = viewModelScope.launch {195 try {196 val paired = receiver.grantFromInvitation(197 encodedInvitation = invitation,198 sourceDeps = sourceDeps(identity),199 confirm = { sas, request ->200 val pending = CompletableDeferred<Boolean>()201 confirmation = pending202 _state.value = AdoptVaultUiState.ConfirmSas(203 sas = sas,204 isSource = true,205 peerName = request.deviceName.ifBlank { "New device" },206 )207 pending.await()208 },209 )210 paired.relayUrl?.let(relaySettings::setRelayUrl)211 _state.value = AdoptVaultUiState.Complete(212 paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",213 )214 } catch (error: CancellationException) {215 throw error216 } catch (error: Throwable) {217 _state.value = AdoptVaultUiState.Failed(218 protocolError ?: error.message ?: "adoption_failed",219 )220 } finally {221 VaultCrypto.zero(keys.secretKey)222 VaultCrypto.zero(keys.publicKey)223 confirmation = null224 clearPassword()225 }226 }227 }228229 fun confirmSas(matches: Boolean) {230 val pending = confirmation ?: return231 confirmation = null232 if (matches) _state.value = AdoptVaultUiState.Adopting233 pending.complete(matches)234 }235236 fun restart() {237 adoptionJob?.cancel()238 adoptionJob = null239 confirmation?.complete(false)240 confirmation = null241 clearPassword()242 _state.value =243 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password244 }245246 private fun receiverDeps(): AdoptionReceiverDeps =247 object : AdoptionReceiverDeps {248 override val deviceName = getDeviceName(appContext)249 override val deviceType = DeviceType.ANDROID250 override fun generateDeviceId(): String = UUID.randomUUID().toString()251252 253 254 override suspend fun confirmSas(): Boolean = false255256 override suspend fun validateSource(source: AdoptionFrame.Source) {257 258 259 260 261 262 263 if (!joining) return264 }265266 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {267 268 269 270 val result = if (joining) {271 val incomingRoot = hexToPubkey(rootKeyHex)272 val incomingVaultId = try {273 computeVaultId(incomingRoot)274 } finally {275 VaultCrypto.zero(incomingRoot)276 }277 AdoptVaultResult.Success(278 requireSameVaultForPopulatedRePair(279 localVaultId = vault.syncVaultId(),280 incomingVaultId = incomingVaultId,281 ),282 )283 } else {284 val localPassword = password285 ?: throw IllegalStateException("adoption_password_unavailable")286 vault.createVaultFromRootKey(localPassword, rootKeyHex)287 }288 return when (result) {289 is AdoptVaultResult.Success -> result.vaultId290 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)291 }292 }293294 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {295 val master = vault.syncMasterKey()296 ?: throw IllegalStateException("adopted_vault_unavailable")297 return try {298 buildLocalIdentity(appContext, master)299 } finally {300 VaultCrypto.zero(master)301 }302 }303304 override suspend fun clearPairedDeviceRevocation(deviceId: String) {305 devices.clearRevocation(deviceId)306 }307308 override suspend fun upsertPairedDevice(device: PairedDevice) {309 devices.upsert(device)310 }311312 override suspend fun applyLicense(licenseKey: String) {313 if (license.validateLicense(licenseKey)) {314 license.storeLicense(licenseKey)315 }316 }317318 override fun onError(error: String) {319 protocolError = error320 }321 }322323 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =324 object : AdoptionSourceDeps {325 override val sourceIdentity: LocalDeviceIdentity = identity326327 override suspend fun rootKeyHex(): String {328 val root = vault.syncMasterKey()329 ?: throw IllegalStateException("vault_locked")330 return try {331 root.joinToString("") { "%02x".format(it) }332 } finally {333 VaultCrypto.zero(root)334 }335 }336337 override suspend fun vaultId(): String =338 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")339340 override suspend fun license(): String? =341 license.getLicenseKey()?.takeIf(license::validateLicense)342343 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true344345 override suspend fun upsertPairedDevice(device: PairedDevice) {346 devices.clearRevocation(device.deviceId)347 devices.upsert(device)348 }349 }350351 fun setBackgroundSync(enabled: Boolean) {352 backgroundSync.setEnabled(enabled)353 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)354 }355356 private fun clearPassword() {357 password = null358 }359360 override fun onCleared() {361 adoptionJob?.cancel()362 confirmation?.complete(false)363 clearPassword()364 super.onCleared()365 }366}367368369370371372373internal fun requireSameVaultForPopulatedRePair(374 localVaultId: String?,375 incomingVaultId: String,376): String {377 if (localVaultId == null) throw IllegalStateException("vault_locked")378 if (localVaultId != incomingVaultId) {379 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")380 }381 return localVaultId382}