AdoptVaultViewModel.kt

15.2 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.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.PeachSyncService16import com.peachpasswords.sync.RelayAdoptionReceiver17import com.peachpasswords.sync.RelaySettingsStore18import com.peachpasswords.sync.RelayDecision19import com.peachpasswords.sync.VaultStatus20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.computeVaultId22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getDeviceName24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.sync.hexToPubkey26import com.peachpasswords.sync.parsePairingInvitation27import com.peachpasswords.ui.security.scorePassword28import com.peachpasswords.vault.AdoptVaultResult29import com.peachpasswords.vault.VaultCrypto30import com.peachpasswords.vault.VaultRepository31import dagger.hilt.android.lifecycle.HiltViewModel32import dagger.hilt.android.qualifiers.ApplicationContext33import java.util.UUID34import javax.inject.Inject35import kotlinx.coroutines.CompletableDeferred36import kotlinx.coroutines.CancellationException37import kotlinx.coroutines.Job38import kotlinx.coroutines.flow.MutableStateFlow39import kotlinx.coroutines.flow.StateFlow40import kotlinx.coroutines.flow.asStateFlow41import kotlinx.coroutines.launch4243sealed interface AdoptVaultUiState {44    data object Password : AdoptVaultUiState45    /**46     * Existing-vault mode only. Pairing is allowed only when the incoming root47     * key identifies this same vault; Peach refuses to combine populated48     * vaults because separately imported copies can otherwise nearly double.49     */50    data object Disclosure : AdoptVaultUiState51    data object Pair : AdoptVaultUiState52    data object Connecting : AdoptVaultUiState53    /**54     * Source-initiator flow exposes [isSource] so the screen can phrase the55     * confirmation as "send vault" instead of "adopt/join vault". The SAS is56     * identical to the receiver flow; only the payload direction differs.57     */58    data class ConfirmSas(59        val sas: String,60        val isSource: Boolean = false,61        val peerName: String? = null,62    ) : AdoptVaultUiState63    data object Adopting : AdoptVaultUiState64    data class Complete(val sourceName: String) : AdoptVaultUiState65    data class Failed(val reason: String) : AdoptVaultUiState66}6768@HiltViewModel69class AdoptVaultViewModel @Inject constructor(70    private val receiver: RelayAdoptionReceiver,71    private val vault: VaultRepository,72    private val devices: PairedDeviceStore,73    private val relaySettings: RelaySettingsStore,74    private val backgroundSync: BackgroundSyncPreference,75    private val license: LicenseRepository,76    @ApplicationContext private val appContext: Context,77) : ViewModel() {78    /**79     * True when this device already has a vault. A populated device may refresh80     * a pairing only for that exact vault identity; it may not adopt a different81     * root key and silently merge two record sets.82     */83    val joining: Boolean = vault.hasVault()8485    private val _state = MutableStateFlow<AdoptVaultUiState>(86        if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,87    )88    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8990    private var password: String? = null91    private var confirmation: CompletableDeferred<Boolean>? = null92    private var adoptionJob: Job? = null93    private var protocolError: String? = null9495    fun acceptPassword(value: String, repeated: String): Boolean {96        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {97            return false98        }99        password = value100        _state.value = AdoptVaultUiState.Pair101        return true102    }103104    /** The user has read what joining does to this device's recovery words. */105    fun acceptDisclosure() {106        if (_state.value is AdoptVaultUiState.Disclosure) {107            _state.value = AdoptVaultUiState.Pair108        }109    }110111    fun adopt(invitation: String) {112        if (_state.value !is AdoptVaultUiState.Pair) return113        val parsed = parsePairingInvitation(invitation)114        if (parsed == null) {115            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")116            clearPassword()117            return118        }119        when (parsed.vaultStatus) {120            VaultStatus.POPULATED -> startReceiverFlow(invitation)121            VaultStatus.EMPTY -> startSourceFlow(invitation)122        }123    }124125    private fun startReceiverFlow(invitation: String) {126        _state.value = AdoptVaultUiState.Connecting127        protocolError = null128        adoptionJob = viewModelScope.launch {129            try {130                val adopted = receiver.adopt(131                    encodedInvitation = invitation,132                    deps = receiverDeps(),133                    confirm = { sas ->134                        val pending = CompletableDeferred<Boolean>()135                        confirmation = pending136                        _state.value = AdoptVaultUiState.ConfirmSas(137                            sas = sas,138                            isSource = false,139                        )140                        pending.await()141                    },142                )143                // The pair carries the source's relay, but the Sync card reads144                // this device's own setting — left alone it would say "Public145                // relay" on a phone dialling a private one. Peach's own URL146                // still lands on PUBLIC, so joining over the public relay is147                // not relabelled private.148                adopted.relayUrl?.let(relaySettings::setRelayUrl)149                _state.value = AdoptVaultUiState.Complete(150                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",151                )152            } catch (error: CancellationException) {153                throw error154            } catch (error: Throwable) {155                _state.value = AdoptVaultUiState.Failed(156                    protocolError ?: error.message ?: "adoption_failed",157                )158            } finally {159                confirmation = null160                clearPassword()161            }162        }163    }164165    /**166     * Source-initiator flow: the scanning invitation came from a vault-less167     * device, so the phone dials the relay, completes Noise, and sends the168     * root key over. Uses the same UI states as the receiver flow so the169     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]170     * distinguishes the role for the confirmation copy.171     */172    private fun startSourceFlow(invitation: String) {173        val master = vault.syncMasterKey()174        if (master == null) {175            _state.value = AdoptVaultUiState.Failed("vault_locked")176            clearPassword()177            return178        }179        when (val decision = relaySettings.relayInForce()) {180            is RelayDecision.Refuse -> {181                VaultCrypto.zero(master)182                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())183                clearPassword()184                return185            }186            is RelayDecision.Dial -> Unit187        }188        _state.value = AdoptVaultUiState.Connecting189        protocolError = null190191        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))192        val identity = buildLocalIdentity(appContext, master)193        VaultCrypto.zero(master)194        adoptionJob = viewModelScope.launch {195            try {196                val paired = receiver.grantFromInvitation(197                    encodedInvitation = invitation,198                    sourceDeps = sourceDeps(identity),199                    confirm = { sas, request ->200                        val pending = CompletableDeferred<Boolean>()201                        confirmation = pending202                        _state.value = AdoptVaultUiState.ConfirmSas(203                            sas = sas,204                            isSource = true,205                            peerName = request.deviceName.ifBlank { "New device" },206                        )207                        pending.await()208                    },209                )210                paired.relayUrl?.let(relaySettings::setRelayUrl)211                _state.value = AdoptVaultUiState.Complete(212                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",213                )214            } catch (error: CancellationException) {215                throw error216            } catch (error: Throwable) {217                _state.value = AdoptVaultUiState.Failed(218                    protocolError ?: error.message ?: "adoption_failed",219                )220            } finally {221                VaultCrypto.zero(keys.secretKey)222                VaultCrypto.zero(keys.publicKey)223                confirmation = null224                clearPassword()225            }226        }227    }228229    fun confirmSas(matches: Boolean) {230        val pending = confirmation ?: return231        confirmation = null232        if (matches) _state.value = AdoptVaultUiState.Adopting233        pending.complete(matches)234    }235236    fun restart() {237        adoptionJob?.cancel()238        adoptionJob = null239        confirmation?.complete(false)240        confirmation = null241        clearPassword()242        _state.value =243            if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password244    }245246    private fun receiverDeps(): AdoptionReceiverDeps =247        object : AdoptionReceiverDeps {248            override val deviceName = getDeviceName(appContext)249            override val deviceType = DeviceType.ANDROID250            override fun generateDeviceId(): String = UUID.randomUUID().toString()251252            // RelayAdoptionReceiver replaces this with the SAS bound to its253            // completed Noise transcript.254            override suspend fun confirmSas(): Boolean = false255256            override suspend fun validateSource(source: AdoptionFrame.Source) {257                // First contact on a populated vault is allowed: a second258                // browser, a tablet, another phone — any device that holds the259                // same root key (the same recovery words) is the same vault.260                // The vault-id check in createVaultFromRootKey is the real261                // security boundary; this callback now only documents intent262                // for callers that want to layer their own policy on top.263                if (!joining) return264            }265266            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {267                // Same protocol seam, two safe outcomes: a fresh device builds268                // around the granted key; a populated device may only refresh a269                // pairing when it already has that exact vault identity.270                val result = if (joining) {271                    val incomingRoot = hexToPubkey(rootKeyHex)272                    val incomingVaultId = try {273                        computeVaultId(incomingRoot)274                    } finally {275                        VaultCrypto.zero(incomingRoot)276                    }277                    AdoptVaultResult.Success(278                        requireSameVaultForPopulatedRePair(279                            localVaultId = vault.syncVaultId(),280                            incomingVaultId = incomingVaultId,281                        ),282                    )283                } else {284                    val localPassword = password285                        ?: throw IllegalStateException("adoption_password_unavailable")286                    vault.createVaultFromRootKey(localPassword, rootKeyHex)287                }288                return when (result) {289                    is AdoptVaultResult.Success -> result.vaultId290                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)291                }292            }293294            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {295                val master = vault.syncMasterKey()296                    ?: throw IllegalStateException("adopted_vault_unavailable")297                return try {298                    buildLocalIdentity(appContext, master)299                } finally {300                    VaultCrypto.zero(master)301                }302            }303304            override suspend fun clearPairedDeviceRevocation(deviceId: String) {305                devices.clearRevocation(deviceId)306            }307308            override suspend fun upsertPairedDevice(device: PairedDevice) {309                devices.upsert(device)310            }311312            override suspend fun applyLicense(licenseKey: String) {313                if (license.validateLicense(licenseKey)) {314                    license.storeLicense(licenseKey)315                }316            }317318            override fun onError(error: String) {319                protocolError = error320            }321        }322323    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =324        object : AdoptionSourceDeps {325            override val sourceIdentity: LocalDeviceIdentity = identity326327            override suspend fun rootKeyHex(): String {328                val root = vault.syncMasterKey()329                    ?: throw IllegalStateException("vault_locked")330                return try {331                    root.joinToString("") { "%02x".format(it) }332                } finally {333                    VaultCrypto.zero(root)334                }335            }336337            override suspend fun vaultId(): String =338                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")339340            override suspend fun license(): String? =341                license.getLicenseKey()?.takeIf(license::validateLicense)342343            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true344345            override suspend fun upsertPairedDevice(device: PairedDevice) {346                devices.clearRevocation(device.deviceId)347                devices.upsert(device)348            }349        }350351    fun setBackgroundSync(enabled: Boolean) {352        backgroundSync.setEnabled(enabled)353        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)354    }355356    private fun clearPassword() {357        password = null358    }359360    override fun onCleared() {361        adoptionJob?.cancel()362        confirmation?.complete(false)363        clearPassword()364        super.onCleared()365    }366}367368/**369 * Pairing is not a merge tool. A populated destination may re-pair only when370 * both sides already derive the same vault id. Different ids must stop before371 * recovery words, entries, revocations, or paired-device state are changed.372 */373internal fun requireSameVaultForPopulatedRePair(374    localVaultId: String?,375    incomingVaultId: String,376): String {377    if (localVaultId == null) throw IllegalStateException("vault_locked")378    if (localVaultId != incomingVaultId) {379        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")380    }381    return localVaultId382}