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.SignedDeviceManifest16import com.peachpasswords.sync.VaultSyncAdapter17import com.peachpasswords.sync.PeachSyncService18import com.peachpasswords.sync.RelayAdoptionReceiver19import com.peachpasswords.sync.RelaySettingsStore20import com.peachpasswords.sync.RelayDecision21import com.peachpasswords.sync.VaultStatus22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.computeVaultId24import com.peachpasswords.sync.deriveLocalSyncKeypair25import com.peachpasswords.sync.getDeviceName26import com.peachpasswords.sync.getOrCreateDeviceId27import com.peachpasswords.sync.hexToPubkey28import com.peachpasswords.sync.parsePairingInvitation29import com.peachpasswords.ui.security.scorePassword30import com.peachpasswords.vault.AdoptVaultResult31import com.peachpasswords.vault.VaultCrypto32import com.peachpasswords.vault.VaultRepository33import dagger.hilt.android.lifecycle.HiltViewModel34import dagger.hilt.android.qualifiers.ApplicationContext35import java.util.UUID36import javax.inject.Inject37import kotlinx.coroutines.CompletableDeferred38import kotlinx.coroutines.CancellationException39import kotlinx.coroutines.Job40import kotlinx.coroutines.flow.MutableStateFlow41import kotlinx.coroutines.flow.StateFlow42import kotlinx.coroutines.flow.asStateFlow43import kotlinx.coroutines.launch4445sealed interface AdoptVaultUiState {46 data object Password : AdoptVaultUiState47 data object Pair : AdoptVaultUiState48 data object Connecting : AdoptVaultUiState49 5051525354 data class ConfirmSas(55 val sas: String,56 val isSource: Boolean = false,57 val peerName: String? = null,58 ) : AdoptVaultUiState59 data object Adopting : AdoptVaultUiState60 data class Complete(val sourceName: String) : AdoptVaultUiState61 data class Failed(val reason: String) : AdoptVaultUiState62}6364@HiltViewModel65class AdoptVaultViewModel @Inject constructor(66 private val receiver: RelayAdoptionReceiver,67 private val vault: VaultRepository,68 private val devices: PairedDeviceStore,69 private val relaySettings: RelaySettingsStore,70 private val backgroundSync: BackgroundSyncPreference,71 private val license: LicenseRepository,72 @ApplicationContext private val appContext: Context,73) : ViewModel() {74 7576777879 val joining: Boolean = vault.hasVault()8081 private val _state = MutableStateFlow<AdoptVaultUiState>(82 initialAdoptVaultState(joining),83 )84 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8586 private var password: String? = null87 private var confirmation: CompletableDeferred<Boolean>? = null88 private var adoptionJob: Job? = null89 private var protocolError: String? = null9091 fun acceptPassword(value: String, repeated: String): Boolean {92 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {93 return false94 }95 password = value96 _state.value = AdoptVaultUiState.Pair97 return true98 }99100 fun adopt(invitation: String) {101 if (_state.value !is AdoptVaultUiState.Pair) return102 val parsed = parsePairingInvitation(invitation)103 if (parsed == null) {104 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")105 clearPassword()106 return107 }108 when (parsed.vaultStatus) {109 VaultStatus.POPULATED -> startReceiverFlow(invitation)110 VaultStatus.EMPTY -> startSourceFlow(invitation)111 }112 }113114 private fun startReceiverFlow(invitation: String) {115 _state.value = AdoptVaultUiState.Connecting116 protocolError = null117 adoptionJob = viewModelScope.launch {118 try {119 val adopted = receiver.adopt(120 encodedInvitation = invitation,121 deps = receiverDeps(),122 confirm = { sas ->123 val pending = CompletableDeferred<Boolean>()124 confirmation = pending125 _state.value = AdoptVaultUiState.ConfirmSas(126 sas = sas,127 isSource = false,128 )129 pending.await()130 },131 )132 133 134 135 136 137 adopted.relayUrl?.let(relaySettings::setRelayUrl)138 _state.value = AdoptVaultUiState.Complete(139 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",140 )141 } catch (error: CancellationException) {142 throw error143 } catch (error: Throwable) {144 _state.value = AdoptVaultUiState.Failed(145 protocolError ?: error.message ?: "adoption_failed",146 )147 } finally {148 confirmation = null149 clearPassword()150 }151 }152 }153154 155156157158159160161 private fun startSourceFlow(invitation: String) {162 val master = vault.syncMasterKey()163 if (master == null) {164 _state.value = AdoptVaultUiState.Failed("vault_locked")165 clearPassword()166 return167 }168 when (val decision = relaySettings.relayInForce()) {169 is RelayDecision.Refuse -> {170 VaultCrypto.zero(master)171 _state.value = AdoptVaultUiState.Failed(decision.reason.wire())172 clearPassword()173 return174 }175 is RelayDecision.Dial -> Unit176 }177 _state.value = AdoptVaultUiState.Connecting178 protocolError = null179180 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))181 val identity = buildLocalIdentity(appContext, master)182 VaultCrypto.zero(master)183 adoptionJob = viewModelScope.launch {184 try {185 val paired = receiver.grantFromInvitation(186 encodedInvitation = invitation,187 sourceDeps = sourceDeps(identity),188 confirm = { sas, request ->189 val pending = CompletableDeferred<Boolean>()190 confirmation = pending191 _state.value = AdoptVaultUiState.ConfirmSas(192 sas = sas,193 isSource = true,194 peerName = request.deviceName.ifBlank { "New device" },195 )196 pending.await()197 },198 )199 paired.relayUrl?.let(relaySettings::setRelayUrl)200 _state.value = AdoptVaultUiState.Complete(201 paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",202 )203 } catch (error: CancellationException) {204 throw error205 } catch (error: Throwable) {206 _state.value = AdoptVaultUiState.Failed(207 protocolError ?: error.message ?: "adoption_failed",208 )209 } finally {210 VaultCrypto.zero(keys.secretKey)211 VaultCrypto.zero(keys.publicKey)212 confirmation = null213 clearPassword()214 }215 }216 }217218 fun confirmSas(matches: Boolean) {219 val pending = confirmation ?: return220 confirmation = null221 if (matches) _state.value = AdoptVaultUiState.Adopting222 pending.complete(matches)223 }224225 fun restart() {226 adoptionJob?.cancel()227 adoptionJob = null228 confirmation?.complete(false)229 confirmation = null230 clearPassword()231 _state.value = initialAdoptVaultState(joining)232 }233234 private fun receiverDeps(): AdoptionReceiverDeps =235 object : AdoptionReceiverDeps {236 override val deviceName = getDeviceName(appContext)237 override val deviceType = DeviceType.ANDROID238 override fun generateDeviceId(): String = UUID.randomUUID().toString()239240 241 242 override suspend fun confirmSas(): Boolean = false243244 override suspend fun validateSource(source: AdoptionFrame.Source) {245 246 247 248 249 250 251 if (!joining) return252 }253254 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {255 256 257 258 val result = if (joining) {259 val incomingRoot = hexToPubkey(rootKeyHex)260 val incomingVaultId = try {261 computeVaultId(incomingRoot)262 } finally {263 VaultCrypto.zero(incomingRoot)264 }265 AdoptVaultResult.Success(266 requireSameVaultForPopulatedRePair(267 localVaultId = vault.syncVaultId(),268 incomingVaultId = incomingVaultId,269 ),270 )271 } else {272 val localPassword = password273 ?: throw IllegalStateException("adoption_password_unavailable")274 vault.createVaultFromRootKey(localPassword, rootKeyHex)275 }276 return when (result) {277 is AdoptVaultResult.Success -> result.vaultId278 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)279 }280 }281282 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {283 val master = vault.syncMasterKey()284 ?: throw IllegalStateException("adopted_vault_unavailable")285 return try {286 buildLocalIdentity(appContext, master)287 } finally {288 VaultCrypto.zero(master)289 }290 }291292 override suspend fun clearPairedDeviceRevocation(deviceId: String) {293 devices.clearRevocation(deviceId)294 }295296 override suspend fun upsertPairedDevice(device: PairedDevice) {297 devices.upsert(device)298 }299300 override suspend fun applyLicense(licenseKey: String) {301 if (!license.validateLicense(licenseKey)) {302 throw IllegalStateException("license_transfer_failed")303 }304 license.storeLicense(licenseKey)305 if (!license.isProActive()) {306 throw IllegalStateException("license_transfer_failed")307 }308 }309310 override suspend fun license(): String? =311 license.getLicenseKey()?.takeIf(license::validateLicense)312313 override fun onError(error: String) {314 protocolError = error315 }316317 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {318 val adapter = VaultSyncAdapter(319 repo = vault,320 ourDeviceId = getOrCreateDeviceId(appContext),321 pairedDevices = devices,322 )323 adapter.applyEntries(entries)324 adapter.applyTombstones(tombstones)325 }326327 override suspend fun markSynced(deviceId: String) {328 devices.updateLastSync(deviceId)329 }330 }331332 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =333 object : AdoptionSourceDeps {334 override val sourceIdentity: LocalDeviceIdentity = identity335336 override suspend fun rootKeyHex(): String {337 val root = vault.syncMasterKey()338 ?: throw IllegalStateException("vault_locked")339 return try {340 root.joinToString("") { "%02x".format(it) }341 } finally {342 VaultCrypto.zero(root)343 }344 }345346 override suspend fun vaultId(): String =347 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")348349 override suspend fun license(): String? =350 license.getLicenseKey()?.takeIf(license::validateLicense)351352 override suspend fun applyLicense(licenseKey: String) {353 if (!license.validateLicense(licenseKey)) {354 throw IllegalStateException("license_transfer_failed")355 }356 license.storeLicense(licenseKey)357 if (!license.isProActive()) {358 throw IllegalStateException("license_transfer_failed")359 }360 }361362 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true363364 override suspend fun upsertPairedDevice(device: PairedDevice) {365 devices.clearRevocation(device.deviceId)366 devices.upsert(device)367 }368369 override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {370 val adapter = VaultSyncAdapter(371 repo = vault,372 ourDeviceId = getOrCreateDeviceId(appContext),373 pairedDevices = devices,374 )375 val entryManifest = adapter.buildEntryManifest()376 val entryIds = entryManifest.keys.toList()377 val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()378 val tombstones = adapter.buildTombstoneManifest()379 return entries to tombstones380 }381382 override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {383 val adapter = VaultSyncAdapter(384 repo = vault,385 ourDeviceId = getOrCreateDeviceId(appContext),386 pairedDevices = devices,387 )388 return adapter.buildOutgoingDeviceManifest()389 }390391 override suspend fun markSynced(deviceId: String) {392 devices.updateLastSync(deviceId)393 }394 }395396 fun setBackgroundSync(enabled: Boolean) {397 backgroundSync.setEnabled(enabled)398 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)399 }400401 private fun clearPassword() {402 password = null403 }404405 override fun onCleared() {406 adoptionJob?.cancel()407 confirmation?.complete(false)408 clearPassword()409 super.onCleared()410 }411}412413internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =414 if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password415416417418419420421internal fun requireSameVaultForPopulatedRePair(422 localVaultId: String?,423 incomingVaultId: String,424): String {425 if (localVaultId == null) throw IllegalStateException("vault_locked")426 if (localVaultId != incomingVaultId) {427 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")428 }429 return localVaultId430}