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.SignedDeviceManifest17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.PeachSyncService19import com.peachpasswords.sync.RelayAdoptionReceiver20import com.peachpasswords.sync.RelaySettingsStore21import com.peachpasswords.sync.RelayDecision22import com.peachpasswords.sync.VaultStatus23import com.peachpasswords.sync.buildLocalIdentity24import com.peachpasswords.sync.computeVaultId25import com.peachpasswords.sync.deriveLocalSyncKeypair26import com.peachpasswords.sync.getDeviceName27import com.peachpasswords.sync.getOrCreateDeviceId28import com.peachpasswords.sync.hexToPubkey29import com.peachpasswords.sync.parsePairingInvitation30import com.peachpasswords.ui.security.scorePassword31import com.peachpasswords.vault.AdoptVaultResult32import com.peachpasswords.vault.VaultCrypto33import com.peachpasswords.vault.VaultRepository34import dagger.hilt.android.lifecycle.HiltViewModel35import dagger.hilt.android.qualifiers.ApplicationContext36import java.util.UUID37import javax.inject.Inject38import kotlinx.coroutines.CompletableDeferred39import kotlinx.coroutines.CancellationException40import kotlinx.coroutines.Job41import kotlinx.coroutines.flow.MutableStateFlow42import kotlinx.coroutines.flow.StateFlow43import kotlinx.coroutines.flow.asStateFlow44import kotlinx.coroutines.launch4546sealed interface AdoptVaultUiState {47 data object Password : AdoptVaultUiState48 data object Pair : AdoptVaultUiState49 data object Connecting : AdoptVaultUiState50 5152535455 data class ConfirmSas(56 val sas: String,57 val isSource: Boolean = false,58 val peerName: String? = null,59 ) : AdoptVaultUiState60 data object Adopting : AdoptVaultUiState61 data class Complete(val sourceName: String) : AdoptVaultUiState62 data class Failed(val reason: String) : AdoptVaultUiState63}6465@HiltViewModel66class AdoptVaultViewModel @Inject constructor(67 private val receiver: RelayAdoptionReceiver,68 private val vault: VaultRepository,69 private val devices: PairedDeviceStore,70 private val relaySettings: RelaySettingsStore,71 private val backgroundSync: BackgroundSyncPreference,72 private val license: LicenseRepository,73 @ApplicationContext private val appContext: Context,74) : ViewModel() {75 7677787980 val joining: Boolean = vault.hasVault()8182 private val _state = MutableStateFlow<AdoptVaultUiState>(83 initialAdoptVaultState(joining),84 )85 val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8687 private var password: String? = null88 private var confirmation: CompletableDeferred<Boolean>? = null89 private var adoptionJob: Job? = null90 private var protocolError: String? = null9192 fun acceptPassword(value: String, repeated: String): Boolean {93 if (value.length < 12 || value != repeated || scorePassword(value) < 2) {94 return false95 }96 password = value97 _state.value = AdoptVaultUiState.Pair98 return true99 }100101 fun adopt(invitation: String) {102 if (_state.value !is AdoptVaultUiState.Pair) return103 val parsed = parsePairingInvitation(invitation)104 if (parsed == null) {105 _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")106 clearPassword()107 return108 }109 when (parsed.vaultStatus) {110 VaultStatus.POPULATED -> startReceiverFlow(invitation)111 VaultStatus.EMPTY -> startSourceFlow(invitation)112 }113 }114115 private fun startReceiverFlow(invitation: String) {116 _state.value = AdoptVaultUiState.Connecting117 protocolError = null118 adoptionJob = viewModelScope.launch {119 try {120 val adopted = receiver.adopt(121 encodedInvitation = invitation,122 deps = receiverDeps(),123 confirm = { sas ->124 val pending = CompletableDeferred<Boolean>()125 confirmation = pending126 _state.value = AdoptVaultUiState.ConfirmSas(127 sas = sas,128 isSource = false,129 )130 pending.await()131 },132 )133 134 135 136 137 138 adopted.relayUrl?.let(relaySettings::setRelayUrl)139 _state.value = AdoptVaultUiState.Complete(140 adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",141 )142 } catch (error: CancellationException) {143 throw error144 } catch (error: Throwable) {145 _state.value = AdoptVaultUiState.Failed(146 protocolError ?: error.message ?: "adoption_failed",147 )148 } finally {149 confirmation = null150 clearPassword()151 }152 }153 }154155 156157158159160161162 private fun startSourceFlow(invitation: String) {163 val master = vault.syncMasterKey()164 if (master == null) {165 _state.value = AdoptVaultUiState.Failed("vault_locked")166 clearPassword()167 return168 }169 when (val decision = relaySettings.relayInForce()) {170 is RelayDecision.Refuse -> {171 VaultCrypto.zero(master)172 _state.value = AdoptVaultUiState.Failed(decision.reason.wire())173 clearPassword()174 return175 }176 is RelayDecision.Dial -> Unit177 }178 _state.value = AdoptVaultUiState.Connecting179 protocolError = null180181 val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))182 val identity = buildLocalIdentity(appContext, master)183 VaultCrypto.zero(master)184 adoptionJob = viewModelScope.launch {185 try {186 val paired = receiver.grantFromInvitation(187 encodedInvitation = invitation,188 sourceDeps = sourceDeps(identity),189 confirm = { sas, request ->190 val pending = CompletableDeferred<Boolean>()191 confirmation = pending192 _state.value = AdoptVaultUiState.ConfirmSas(193 sas = sas,194 isSource = true,195 peerName = request.deviceName.ifBlank { "New device" },196 )197 pending.await()198 },199 )200 paired.relayUrl?.let(relaySettings::setRelayUrl)201 _state.value = AdoptVaultUiState.Complete(202 paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",203 )204 } catch (error: CancellationException) {205 throw error206 } catch (error: Throwable) {207 _state.value = AdoptVaultUiState.Failed(208 protocolError ?: error.message ?: "adoption_failed",209 )210 } finally {211 VaultCrypto.zero(keys.secretKey)212 VaultCrypto.zero(keys.publicKey)213 confirmation = null214 clearPassword()215 }216 }217 }218219 fun confirmSas(matches: Boolean) {220 val pending = confirmation ?: return221 confirmation = null222 if (matches) _state.value = AdoptVaultUiState.Adopting223 pending.complete(matches)224 }225226 fun restart() {227 adoptionJob?.cancel()228 adoptionJob = null229 confirmation?.complete(false)230 confirmation = null231 clearPassword()232 _state.value = initialAdoptVaultState(joining)233 }234235 private fun receiverDeps(): AdoptionReceiverDeps =236 object : AdoptionReceiverDeps {237 override val deviceName = getDeviceName(appContext)238 override val deviceType = DeviceType.ANDROID239 override fun generateDeviceId(): String = UUID.randomUUID().toString()240241 242 243 override suspend fun confirmSas(): Boolean = false244245 override suspend fun validateSource(source: AdoptionFrame.Source) {246 247 248 249 250 251 252 if (!joining) return253 }254255 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {256 257 258 259 val result = if (joining) {260 val incomingRoot = hexToPubkey(rootKeyHex)261 val incomingVaultId = try {262 computeVaultId(incomingRoot)263 } finally {264 VaultCrypto.zero(incomingRoot)265 }266 AdoptVaultResult.Success(267 requireSameVaultForPopulatedRePair(268 localVaultId = vault.syncVaultId(),269 incomingVaultId = incomingVaultId,270 ),271 )272 } else {273 val localPassword = password274 ?: throw IllegalStateException("adoption_password_unavailable")275 vault.createVaultFromRootKey(localPassword, rootKeyHex)276 }277 return when (result) {278 is AdoptVaultResult.Success -> result.vaultId279 is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)280 }281 }282283 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {284 val master = vault.syncMasterKey()285 ?: throw IllegalStateException("adopted_vault_unavailable")286 return try {287 buildLocalIdentity(appContext, master)288 } finally {289 VaultCrypto.zero(master)290 }291 }292293 override suspend fun clearPairedDeviceRevocation(deviceId: String) {294 devices.clearRevocation(deviceId)295 }296297 override suspend fun upsertPairedDevice(device: PairedDevice) {298 devices.upsert(device)299 }300301 override suspend fun applyLicense(licenseKey: String) {302 if (!license.validateLicense(licenseKey)) {303 throw IllegalStateException("license_transfer_failed")304 }305 license.storeLicense(licenseKey)306 if (!license.isProActive()) {307 throw IllegalStateException("license_transfer_failed")308 }309 }310311 override suspend fun license(): String? =312 license.getLicenseKey()?.takeIf(license::validateLicense)313314 override fun onError(error: String) {315 protocolError = error316 }317318 override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {319 val adapter = VaultSyncAdapter(320 repo = vault,321 ourDeviceId = getOrCreateDeviceId(appContext),322 pairedDevices = devices,323 )324 adapter.applyEntries(entries)325 adapter.applyTombstones(tombstones)326 if (!duressSlot.isNullOrBlank()) {327 try {328 val ok = vault.installAdoptedDuressSlot(duressSlot)329 if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")330 } catch (e: Exception) {331 Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)332 }333 }334 }335336 override suspend fun markSynced(deviceId: String) {337 devices.updateLastSync(deviceId)338 }339 }340341 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =342 object : AdoptionSourceDeps {343 override val sourceIdentity: LocalDeviceIdentity = identity344345 override suspend fun rootKeyHex(): String {346 val root = vault.syncMasterKey()347 ?: throw IllegalStateException("vault_locked")348 return try {349 root.joinToString("") { "%02x".format(it) }350 } finally {351 VaultCrypto.zero(root)352 }353 }354355 override suspend fun vaultId(): String =356 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")357358 override suspend fun license(): String? =359 license.getLicenseKey()?.takeIf(license::validateLicense)360361 override suspend fun applyLicense(licenseKey: String) {362 if (!license.validateLicense(licenseKey)) {363 throw IllegalStateException("license_transfer_failed")364 }365 license.storeLicense(licenseKey)366 if (!license.isProActive()) {367 throw IllegalStateException("license_transfer_failed")368 }369 }370371 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true372373 override suspend fun upsertPairedDevice(device: PairedDevice) {374 devices.clearRevocation(device.deviceId)375 devices.upsert(device)376 }377378 override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {379 val adapter = VaultSyncAdapter(380 repo = vault,381 ourDeviceId = getOrCreateDeviceId(appContext),382 pairedDevices = devices,383 )384 val entryManifest = adapter.buildEntryManifest()385 val entryIds = entryManifest.keys.toList()386 val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()387 val tombstones = adapter.buildTombstoneManifest()388 return entries to tombstones389 }390391 override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {392 val adapter = VaultSyncAdapter(393 repo = vault,394 ourDeviceId = getOrCreateDeviceId(appContext),395 pairedDevices = devices,396 )397 return adapter.buildOutgoingDeviceManifest()398 }399400 override suspend fun markSynced(deviceId: String) {401 devices.updateLastSync(deviceId)402 }403404 override suspend fun duressSlotJson(): String? =405 vault.exportDuressSlotForAdoption()406 }407408 fun setBackgroundSync(enabled: Boolean) {409 backgroundSync.setEnabled(enabled)410 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)411 }412413 private fun clearPassword() {414 password = null415 }416417 override fun onCleared() {418 adoptionJob?.cancel()419 confirmation?.complete(false)420 clearPassword()421 super.onCleared()422 }423}424425internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =426 if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password427428429430431432433internal fun requireSameVaultForPopulatedRePair(434 localVaultId: String?,435 incomingVaultId: String,436): String {437 if (localVaultId == null) throw IllegalStateException("vault_locked")438 if (localVaultId != incomingVaultId) {439 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")440 }441 return localVaultId442}