AdoptVaultViewModel.kt

11.5 KB · raw

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    /**43     * Existing-vault mode only. Pairing is allowed only when the incoming root44     * key identifies this same vault; Peach refuses to combine populated45     * vaults because separately imported copies can otherwise nearly double.46     */47    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    /**67     * True when this device already has a vault. A populated device may refresh68     * a pairing only for that exact vault identity; it may not adopt a different69     * root key and silently merge two record sets.70     */71    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    /** The user has read what joining does to this device's recovery words. */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                // The pair carries the source's relay, but the Sync card reads121                // this device's own setting — left alone it would say "Public122                // relay" on a phone dialling a private one. Peach's own URL123                // still lands on PUBLIC, so joining over the public relay is124                // not relabelled private.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            // RelayAdoptionReceiver replaces this with the SAS bound to its166            // completed Noise transcript.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                // Same protocol seam, two safe outcomes: a fresh device builds181                // around the granted key; a populated device may only refresh a182                // pairing when it already has that exact vault identity.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}252253/**254 * Pairing is not a merge tool. A populated destination may re-pair only when255 * both sides already derive the same vault id. Different ids must stop before256 * recovery words, entries, revocations, or paired-device state are changed.257 */258internal 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}268269/**270 * Matching vault roots do not prove that record ids match. Two independently271 * restored copies can have the same human-visible credentials under different272 * ids, and a first sync would union them. A populated destination therefore273 * accepts only a peer already present in its direct-pair or revocation history.274 * First contact must use an empty destination.275 */276internal 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}