package com.peachpasswords.ui.pairing import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peachpasswords.license.LicenseRepository import com.peachpasswords.sync.AdoptionReceiverDeps import com.peachpasswords.sync.BackgroundSyncPreference import com.peachpasswords.sync.CryptoCompare import com.peachpasswords.sync.DeviceRevocation import com.peachpasswords.sync.DeviceType import com.peachpasswords.sync.AdoptionFrame import com.peachpasswords.sync.LocalDeviceIdentity import com.peachpasswords.sync.PairedDevice import com.peachpasswords.sync.PairedDeviceStore import com.peachpasswords.sync.PeachSyncService import com.peachpasswords.sync.RelayAdoptionReceiver import com.peachpasswords.sync.RelaySettingsStore import com.peachpasswords.sync.buildLocalIdentity import com.peachpasswords.sync.computeVaultId import com.peachpasswords.sync.getDeviceName import com.peachpasswords.sync.hexToPubkey import com.peachpasswords.sync.parsePairingInvitation import com.peachpasswords.ui.security.scorePassword import com.peachpasswords.vault.AdoptVaultResult import com.peachpasswords.vault.VaultCrypto import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch sealed interface AdoptVaultUiState { data object Password : AdoptVaultUiState /** * Existing-vault mode only. Pairing is allowed only when the incoming root * key identifies this same vault; Peach refuses to combine populated * vaults because separately imported copies can otherwise nearly double. */ data object Disclosure : AdoptVaultUiState data object Pair : AdoptVaultUiState data object Connecting : AdoptVaultUiState data class ConfirmSas(val sas: String) : AdoptVaultUiState data object Adopting : AdoptVaultUiState data class Complete(val sourceName: String) : AdoptVaultUiState data class Failed(val reason: String) : AdoptVaultUiState } @HiltViewModel class AdoptVaultViewModel @Inject constructor( private val receiver: RelayAdoptionReceiver, private val vault: VaultRepository, private val devices: PairedDeviceStore, private val relaySettings: RelaySettingsStore, private val backgroundSync: BackgroundSyncPreference, private val license: LicenseRepository, @ApplicationContext private val appContext: Context, ) : ViewModel() { /** * True when this device already has a vault. A populated device may refresh * a pairing only for that exact vault identity; it may not adopt a different * root key and silently merge two record sets. */ val joining: Boolean = vault.hasVault() private val _state = MutableStateFlow( if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password, ) val state: StateFlow = _state.asStateFlow() private var password: String? = null private var confirmation: CompletableDeferred? = null private var adoptionJob: Job? = null private var protocolError: String? = null fun acceptPassword(value: String, repeated: String): Boolean { if (value.length < 12 || value != repeated || scorePassword(value) < 2) { return false } password = value _state.value = AdoptVaultUiState.Pair return true } /** The user has read what joining does to this device's recovery words. */ fun acceptDisclosure() { if (_state.value is AdoptVaultUiState.Disclosure) { _state.value = AdoptVaultUiState.Pair } } fun adopt(invitation: String) { if (_state.value !is AdoptVaultUiState.Pair) return if (parsePairingInvitation(invitation) == null) { _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation") clearPassword() return } _state.value = AdoptVaultUiState.Connecting protocolError = null adoptionJob = viewModelScope.launch { try { val adopted = receiver.adopt( encodedInvitation = invitation, deps = receiverDeps(), confirm = { sas -> val pending = CompletableDeferred() confirmation = pending _state.value = AdoptVaultUiState.ConfirmSas(sas) pending.await() }, ) // The pair carries the source's relay, but the Sync card reads // this device's own setting — left alone it would say "Public // relay" on a phone dialling a private one. Peach's own URL // still lands on PUBLIC, so joining over the public relay is // not relabelled private. adopted.relayUrl?.let(relaySettings::setRelayUrl) _state.value = AdoptVaultUiState.Complete( adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device", ) } catch (error: CancellationException) { throw error } catch (error: Throwable) { _state.value = AdoptVaultUiState.Failed( protocolError ?: error.message ?: "adoption_failed", ) } finally { confirmation = null clearPassword() } } } fun confirmSas(matches: Boolean) { val pending = confirmation ?: return confirmation = null if (matches) _state.value = AdoptVaultUiState.Adopting pending.complete(matches) } fun restart() { adoptionJob?.cancel() adoptionJob = null confirmation?.complete(false) confirmation = null clearPassword() _state.value = if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password } private fun receiverDeps(): AdoptionReceiverDeps = object : AdoptionReceiverDeps { override val deviceName = getDeviceName(appContext) override val deviceType = DeviceType.ANDROID override fun generateDeviceId(): String = UUID.randomUUID().toString() // RelayAdoptionReceiver replaces this with the SAS bound to its // completed Noise transcript. override suspend fun confirmSas(): Boolean = false override suspend fun validateSource(source: AdoptionFrame.Source) { if (!joining) return requireKnownPeerForPopulatedRePair( sourceDeviceId = source.deviceId, sourcePublicKeyHex = source.longTermPublicKeyHex, pairedDevices = devices.getAll(), revocations = devices.listRevocations(), ) } override suspend fun createVaultFromRootKey(rootKeyHex: String): String { // Same protocol seam, two safe outcomes: a fresh device builds // around the granted key; a populated device may only refresh a // pairing when it already has that exact vault identity. val result = if (joining) { val incomingRoot = hexToPubkey(rootKeyHex) val incomingVaultId = try { computeVaultId(incomingRoot) } finally { VaultCrypto.zero(incomingRoot) } AdoptVaultResult.Success( requireSameVaultForPopulatedRePair( localVaultId = vault.syncVaultId(), incomingVaultId = incomingVaultId, ), ) } else { val localPassword = password ?: throw IllegalStateException("adoption_password_unavailable") vault.createVaultFromRootKey(localPassword, rootKeyHex) } return when (result) { is AdoptVaultResult.Success -> result.vaultId is AdoptVaultResult.Error -> throw IllegalStateException(result.reason) } } override suspend fun deriveLocalIdentity(): LocalDeviceIdentity { val master = vault.syncMasterKey() ?: throw IllegalStateException("adopted_vault_unavailable") return try { buildLocalIdentity(appContext, master) } finally { VaultCrypto.zero(master) } } override suspend fun clearPairedDeviceRevocation(deviceId: String) { devices.clearRevocation(deviceId) } override suspend fun upsertPairedDevice(device: PairedDevice) { devices.upsert(device) } override suspend fun applyLicense(licenseKey: String) { if (license.validateLicense(licenseKey)) { license.storeLicense(licenseKey) } } override fun onError(error: String) { protocolError = error } } fun setBackgroundSync(enabled: Boolean) { backgroundSync.setEnabled(enabled) if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext) } private fun clearPassword() { password = null } override fun onCleared() { adoptionJob?.cancel() confirmation?.complete(false) clearPassword() super.onCleared() } } /** * Pairing is not a merge tool. A populated destination may re-pair only when * both sides already derive the same vault id. Different ids must stop before * recovery words, entries, revocations, or paired-device state are changed. */ internal fun requireSameVaultForPopulatedRePair( localVaultId: String?, incomingVaultId: String, ): String { if (localVaultId == null) throw IllegalStateException("vault_locked") if (localVaultId != incomingVaultId) { throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED") } return localVaultId } /** * Matching vault roots do not prove that record ids match. Two independently * restored copies can have the same human-visible credentials under different * ids, and a first sync would union them. A populated destination therefore * accepts only a peer already present in its direct-pair or revocation history. * First contact must use an empty destination. */ internal fun requireKnownPeerForPopulatedRePair( sourceDeviceId: String, sourcePublicKeyHex: String, pairedDevices: List, revocations: List, ) { val pairedMatch = pairedDevices.any { it.deviceId == sourceDeviceId && it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, sourcePublicKeyHex) } == true } val revokedMatch = revocations.any { it.deviceId == sourceDeviceId && CryptoCompare.hex(it.longTermPublicKeyHex, sourcePublicKeyHex) } if (!pairedMatch && !revokedMatch) { throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED") } }