AdoptVaultViewModel.kt

14.7 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    data object Pair : AdoptVaultUiState46    data object Connecting : AdoptVaultUiState47    /**48     * Source-initiator flow exposes [isSource] so the screen can phrase the49     * confirmation as "send vault" instead of "adopt/join vault". The SAS is50     * identical to the receiver flow; only the payload direction differs.51     */52    data class ConfirmSas(53        val sas: String,54        val isSource: Boolean = false,55        val peerName: String? = null,56    ) : AdoptVaultUiState57    data object Adopting : AdoptVaultUiState58    data class Complete(val sourceName: String) : AdoptVaultUiState59    data class Failed(val reason: String) : AdoptVaultUiState60}6162@HiltViewModel63class AdoptVaultViewModel @Inject constructor(64    private val receiver: RelayAdoptionReceiver,65    private val vault: VaultRepository,66    private val devices: PairedDeviceStore,67    private val relaySettings: RelaySettingsStore,68    private val backgroundSync: BackgroundSyncPreference,69    private val license: LicenseRepository,70    @ApplicationContext private val appContext: Context,71) : ViewModel() {72    /**73     * True when this device already has a vault. A populated device may refresh74     * a pairing only for that exact vault identity; it may not adopt a different75     * root key and silently merge two record sets.76     */77    val joining: Boolean = vault.hasVault()7879    private val _state = MutableStateFlow<AdoptVaultUiState>(80        initialAdoptVaultState(joining),81    )82    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8384    private var password: String? = null85    private var confirmation: CompletableDeferred<Boolean>? = null86    private var adoptionJob: Job? = null87    private var protocolError: String? = null8889    fun acceptPassword(value: String, repeated: String): Boolean {90        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {91            return false92        }93        password = value94        _state.value = AdoptVaultUiState.Pair95        return true96    }9798    fun adopt(invitation: String) {99        if (_state.value !is AdoptVaultUiState.Pair) return100        val parsed = parsePairingInvitation(invitation)101        if (parsed == null) {102            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")103            clearPassword()104            return105        }106        when (parsed.vaultStatus) {107            VaultStatus.POPULATED -> startReceiverFlow(invitation)108            VaultStatus.EMPTY -> startSourceFlow(invitation)109        }110    }111112    private fun startReceiverFlow(invitation: String) {113        _state.value = AdoptVaultUiState.Connecting114        protocolError = null115        adoptionJob = viewModelScope.launch {116            try {117                val adopted = receiver.adopt(118                    encodedInvitation = invitation,119                    deps = receiverDeps(),120                    confirm = { sas ->121                        val pending = CompletableDeferred<Boolean>()122                        confirmation = pending123                        _state.value = AdoptVaultUiState.ConfirmSas(124                            sas = sas,125                            isSource = false,126                        )127                        pending.await()128                    },129                )130                // The pair carries the source's relay, but the Sync card reads131                // this device's own setting — left alone it would say "Public132                // relay" on a phone dialling a private one. Peach's own URL133                // still lands on PUBLIC, so joining over the public relay is134                // not relabelled private.135                adopted.relayUrl?.let(relaySettings::setRelayUrl)136                _state.value = AdoptVaultUiState.Complete(137                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",138                )139            } catch (error: CancellationException) {140                throw error141            } catch (error: Throwable) {142                _state.value = AdoptVaultUiState.Failed(143                    protocolError ?: error.message ?: "adoption_failed",144                )145            } finally {146                confirmation = null147                clearPassword()148            }149        }150    }151152    /**153     * Source-initiator flow: the scanning invitation came from a vault-less154     * device, so the phone dials the relay, completes Noise, and sends the155     * root key over. Uses the same UI states as the receiver flow so the156     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]157     * distinguishes the role for the confirmation copy.158     */159    private fun startSourceFlow(invitation: String) {160        val master = vault.syncMasterKey()161        if (master == null) {162            _state.value = AdoptVaultUiState.Failed("vault_locked")163            clearPassword()164            return165        }166        when (val decision = relaySettings.relayInForce()) {167            is RelayDecision.Refuse -> {168                VaultCrypto.zero(master)169                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())170                clearPassword()171                return172            }173            is RelayDecision.Dial -> Unit174        }175        _state.value = AdoptVaultUiState.Connecting176        protocolError = null177178        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))179        val identity = buildLocalIdentity(appContext, master)180        VaultCrypto.zero(master)181        adoptionJob = viewModelScope.launch {182            try {183                val paired = receiver.grantFromInvitation(184                    encodedInvitation = invitation,185                    sourceDeps = sourceDeps(identity),186                    confirm = { sas, request ->187                        val pending = CompletableDeferred<Boolean>()188                        confirmation = pending189                        _state.value = AdoptVaultUiState.ConfirmSas(190                            sas = sas,191                            isSource = true,192                            peerName = request.deviceName.ifBlank { "New device" },193                        )194                        pending.await()195                    },196                )197                paired.relayUrl?.let(relaySettings::setRelayUrl)198                _state.value = AdoptVaultUiState.Complete(199                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",200                )201            } catch (error: CancellationException) {202                throw error203            } catch (error: Throwable) {204                _state.value = AdoptVaultUiState.Failed(205                    protocolError ?: error.message ?: "adoption_failed",206                )207            } finally {208                VaultCrypto.zero(keys.secretKey)209                VaultCrypto.zero(keys.publicKey)210                confirmation = null211                clearPassword()212            }213        }214    }215216    fun confirmSas(matches: Boolean) {217        val pending = confirmation ?: return218        confirmation = null219        if (matches) _state.value = AdoptVaultUiState.Adopting220        pending.complete(matches)221    }222223    fun restart() {224        adoptionJob?.cancel()225        adoptionJob = null226        confirmation?.complete(false)227        confirmation = null228        clearPassword()229        _state.value = initialAdoptVaultState(joining)230    }231232    private fun receiverDeps(): AdoptionReceiverDeps =233        object : AdoptionReceiverDeps {234            override val deviceName = getDeviceName(appContext)235            override val deviceType = DeviceType.ANDROID236            override fun generateDeviceId(): String = UUID.randomUUID().toString()237238            // RelayAdoptionReceiver replaces this with the SAS bound to its239            // completed Noise transcript.240            override suspend fun confirmSas(): Boolean = false241242            override suspend fun validateSource(source: AdoptionFrame.Source) {243                // First contact on a populated vault is allowed: a second244                // browser, a tablet, another phone — any device that holds the245                // same root key (the same recovery words) is the same vault.246                // The vault-id check in createVaultFromRootKey is the real247                // security boundary; this callback now only documents intent248                // for callers that want to layer their own policy on top.249                if (!joining) return250            }251252            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {253                // Same protocol seam, two safe outcomes: a fresh device builds254                // around the granted key; a populated device may only refresh a255                // pairing when it already has that exact vault identity.256                val result = if (joining) {257                    val incomingRoot = hexToPubkey(rootKeyHex)258                    val incomingVaultId = try {259                        computeVaultId(incomingRoot)260                    } finally {261                        VaultCrypto.zero(incomingRoot)262                    }263                    AdoptVaultResult.Success(264                        requireSameVaultForPopulatedRePair(265                            localVaultId = vault.syncVaultId(),266                            incomingVaultId = incomingVaultId,267                        ),268                    )269                } else {270                    val localPassword = password271                        ?: throw IllegalStateException("adoption_password_unavailable")272                    vault.createVaultFromRootKey(localPassword, rootKeyHex)273                }274                return when (result) {275                    is AdoptVaultResult.Success -> result.vaultId276                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)277                }278            }279280            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {281                val master = vault.syncMasterKey()282                    ?: throw IllegalStateException("adopted_vault_unavailable")283                return try {284                    buildLocalIdentity(appContext, master)285                } finally {286                    VaultCrypto.zero(master)287                }288            }289290            override suspend fun clearPairedDeviceRevocation(deviceId: String) {291                devices.clearRevocation(deviceId)292            }293294            override suspend fun upsertPairedDevice(device: PairedDevice) {295                devices.upsert(device)296            }297298            override suspend fun applyLicense(licenseKey: String) {299                if (license.validateLicense(licenseKey)) {300                    license.storeLicense(licenseKey)301                }302            }303304            override fun onError(error: String) {305                protocolError = error306            }307        }308309    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =310        object : AdoptionSourceDeps {311            override val sourceIdentity: LocalDeviceIdentity = identity312313            override suspend fun rootKeyHex(): String {314                val root = vault.syncMasterKey()315                    ?: throw IllegalStateException("vault_locked")316                return try {317                    root.joinToString("") { "%02x".format(it) }318                } finally {319                    VaultCrypto.zero(root)320                }321            }322323            override suspend fun vaultId(): String =324                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")325326            override suspend fun license(): String? =327                license.getLicenseKey()?.takeIf(license::validateLicense)328329            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true330331            override suspend fun upsertPairedDevice(device: PairedDevice) {332                devices.clearRevocation(device.deviceId)333                devices.upsert(device)334            }335        }336337    fun setBackgroundSync(enabled: Boolean) {338        backgroundSync.setEnabled(enabled)339        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)340    }341342    private fun clearPassword() {343        password = null344    }345346    override fun onCleared() {347        adoptionJob?.cancel()348        confirmation?.complete(false)349        clearPassword()350        super.onCleared()351    }352}353354internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =355    if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password356357/**358 * Pairing is not a merge tool. A populated destination may re-pair only when359 * both sides already derive the same vault id. Different ids must stop before360 * recovery words, entries, revocations, or paired-device state are changed.361 */362internal fun requireSameVaultForPopulatedRePair(363    localVaultId: String?,364    incomingVaultId: String,365): String {366    if (localVaultId == null) throw IllegalStateException("vault_locked")367    if (localVaultId != incomingVaultId) {368        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")369    }370    return localVaultId371}