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.BackgroundSyncPreference9import com.peachpasswords.sync.CryptoCompare10import com.peachpasswords.sync.DeviceRevocation11import com.peachpasswords.sync.DeviceType12import com.peachpasswords.sync.AdoptionFrame13import com.peachpasswords.sync.LocalDeviceIdentity14import com.peachpasswords.sync.PairedDevice15import com.peachpasswords.sync.PairedDeviceStore16import com.peachpasswords.sync.PeachSyncService17import com.peachpasswords.sync.RelayAdoptionReceiver18import com.peachpasswords.sync.RelaySettingsStore19import com.peachpasswords.sync.buildLocalIdentity20import com.peachpasswords.sync.computeVaultId21import com.peachpasswords.sync.getDeviceName22import com.peachpasswords.sync.hexToPubkey23import com.peachpasswords.sync.parsePairingInvitation24import com.peachpasswords.ui.security.scorePassword25import com.peachpasswords.vault.AdoptVaultResult26import com.peachpasswords.vault.VaultCrypto27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import java.util.UUID31import javax.inject.Inject32import kotlinx.coroutines.CompletableDeferred33import kotlinx.coroutines.CancellationException34import kotlinx.coroutines.Job35import kotlinx.coroutines.flow.MutableStateFlow36import kotlinx.coroutines.flow.StateFlow37import kotlinx.coroutines.flow.asStateFlow38import kotlinx.coroutines.launch3940sealed interface AdoptVaultUiState {41 data object Password : AdoptVaultUiState42 4344454647 data object Disclosure : AdoptVaultUiState48 data object Pair : AdoptVaultUiState49 data object Connecting : AdoptVaultUiState50 data class ConfirmSas(val sas: String) : AdoptVaultUiState51 data object Adopting : AdoptVaultUiState52 data class Complete(val sourceName: String) : AdoptVaultUiState53 data class Failed(val reason: String) : AdoptVaultUiState54}5556@HiltViewModel57class AdoptVaultViewModel @Inject constructor(58 private val receiver: RelayAdoptionReceiver,59 private val vault: VaultRepository,60 private val devices: PairedDeviceStore,61 private val relaySettings: RelaySettingsStore,62 private val backgroundSync: BackgroundSyncPreference,63 private val license: LicenseRepository,64 @ApplicationContext private val appContext: Context,65) : ViewModel() {66 6768697071 val joining: Boolean = vault.hasVault()7273 private val _state = MutableStateFlow<AdoptVaultUiState>(74 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,75 )76 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7778 private var password: String? = null79 private var confirmation: CompletableDeferred<Boolean>? = null80 private var adoptionJob: Job? = null81 private var protocolError: String? = null8283 fun acceptPassword(value: String, repeated: String): Boolean {84 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {85 return false86 }87 password = value88 _state.value = AdoptVaultUiState.Pair89 return true90 }9192 93 fun acceptDisclosure() {94 if (_state.value is AdoptVaultUiState.Disclosure) {95 _state.value = AdoptVaultUiState.Pair96 }97 }9899 fun adopt(invitation: String) {100 if (_state.value !is AdoptVaultUiState.Pair) return101 if (parsePairingInvitation(invitation) == null) {102 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")103 clearPassword()104 return105 }106 _state.value = AdoptVaultUiState.Connecting107 protocolError = null108 adoptionJob = viewModelScope.launch {109 try {110 val adopted = receiver.adopt(111 encodedInvitation = invitation,112 deps = receiverDeps(),113 confirm = { sas ->114 val pending = CompletableDeferred<Boolean>()115 confirmation = pending116 _state.value = AdoptVaultUiState.ConfirmSas(sas)117 pending.await()118 },119 )120 121 122 123 124 125 adopted.relayUrl?.let(relaySettings::setRelayUrl)126 _state.value = AdoptVaultUiState.Complete(127 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",128 )129 } catch (error: CancellationException) {130 throw error131 } catch (error: Throwable) {132 _state.value = AdoptVaultUiState.Failed(133 protocolError ?: error.message ?: "adoption_failed",134 )135 } finally {136 confirmation = null137 clearPassword()138 }139 }140 }141142 fun confirmSas(matches: Boolean) {143 val pending = confirmation ?: return144 confirmation = null145 if (matches) _state.value = AdoptVaultUiState.Adopting146 pending.complete(matches)147 }148149 fun restart() {150 adoptionJob?.cancel()151 adoptionJob = null152 confirmation?.complete(false)153 confirmation = null154 clearPassword()155 _state.value =156 if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password157 }158159 private fun receiverDeps(): AdoptionReceiverDeps =160 object : AdoptionReceiverDeps {161 override val deviceName = getDeviceName(appContext)162 override val deviceType = DeviceType.ANDROID163 override fun generateDeviceId(): String = UUID.randomUUID().toString()164165 166 167 override suspend fun confirmSas(): Boolean = false168169 override suspend fun validateSource(source: AdoptionFrame.Source) {170 if (!joining) return171 requireKnownPeerForPopulatedRePair(172 sourceDeviceId = source.deviceId,173 sourcePublicKeyHex = source.longTermPublicKeyHex,174 pairedDevices = devices.getAll(),175 revocations = devices.listRevocations(),176 )177 }178179 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {180 181 182 183 val result = if (joining) {184 val incomingRoot = hexToPubkey(rootKeyHex)185 val incomingVaultId = try {186 computeVaultId(incomingRoot)187 } finally {188 VaultCrypto.zero(incomingRoot)189 }190 AdoptVaultResult.Success(191 requireSameVaultForPopulatedRePair(192 localVaultId = vault.syncVaultId(),193 incomingVaultId = incomingVaultId,194 ),195 )196 } else {197 val localPassword = password198 ?: throw IllegalStateException("adoption_password_unavailable")199 vault.createVaultFromRootKey(localPassword, rootKeyHex)200 }201 return when (result) {202 is AdoptVaultResult.Success -> result.vaultId203 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)204 }205 }206207 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {208 val master = vault.syncMasterKey()209 ?: throw IllegalStateException("adopted_vault_unavailable")210 return try {211 buildLocalIdentity(appContext, master)212 } finally {213 VaultCrypto.zero(master)214 }215 }216217 override suspend fun clearPairedDeviceRevocation(deviceId: String) {218 devices.clearRevocation(deviceId)219 }220221 override suspend fun upsertPairedDevice(device: PairedDevice) {222 devices.upsert(device)223 }224225 override suspend fun applyLicense(licenseKey: String) {226 if (license.validateLicense(licenseKey)) {227 license.storeLicense(licenseKey)228 }229 }230231 override fun onError(error: String) {232 protocolError = error233 }234 }235236 fun setBackgroundSync(enabled: Boolean) {237 backgroundSync.setEnabled(enabled)238 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)239 }240241 private fun clearPassword() {242 password = null243 }244245 override fun onCleared() {246 adoptionJob?.cancel()247 confirmation?.complete(false)248 clearPassword()249 super.onCleared()250 }251}252253254255256257258internal fun requireSameVaultForPopulatedRePair(259 localVaultId: String?,260 incomingVaultId: String,261): String {262 if (localVaultId == null) throw IllegalStateException("vault_locked")263 if (localVaultId != incomingVaultId) {264 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")265 }266 return localVaultId267}268269270271272273274275276internal fun requireKnownPeerForPopulatedRePair(277 sourceDeviceId: String,278 sourcePublicKeyHex: String,279 pairedDevices: List<PairedDevice>,280 revocations: List<DeviceRevocation>,281) {282 val pairedMatch = pairedDevices.any {283 it.deviceId == sourceDeviceId &&284 it.longTermPublicKeyHex?.let { key ->285 CryptoCompare.hex(key, sourcePublicKeyHex)286 } == true287 }288 val revokedMatch = revocations.any {289 it.deviceId == sourceDeviceId &&290 CryptoCompare.hex(it.longTermPublicKeyHex, sourcePublicKeyHex)291 }292 if (!pairedMatch && !revokedMatch) {293 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")294 }295}