AdoptVaultViewModel.kt

17.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.SignedDeviceManifest16import com.peachpasswords.sync.VaultSyncAdapter17import com.peachpasswords.sync.PeachSyncService18import com.peachpasswords.sync.RelayAdoptionReceiver19import com.peachpasswords.sync.RelaySettingsStore20import com.peachpasswords.sync.RelayDecision21import com.peachpasswords.sync.VaultStatus22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.computeVaultId24import com.peachpasswords.sync.deriveLocalSyncKeypair25import com.peachpasswords.sync.getDeviceName26import com.peachpasswords.sync.getOrCreateDeviceId27import com.peachpasswords.sync.hexToPubkey28import com.peachpasswords.sync.parsePairingInvitation29import com.peachpasswords.ui.security.scorePassword30import com.peachpasswords.vault.AdoptVaultResult31import com.peachpasswords.vault.VaultCrypto32import com.peachpasswords.vault.VaultRepository33import dagger.hilt.android.lifecycle.HiltViewModel34import dagger.hilt.android.qualifiers.ApplicationContext35import java.util.UUID36import javax.inject.Inject37import kotlinx.coroutines.CompletableDeferred38import kotlinx.coroutines.CancellationException39import kotlinx.coroutines.Job40import kotlinx.coroutines.flow.MutableStateFlow41import kotlinx.coroutines.flow.StateFlow42import kotlinx.coroutines.flow.asStateFlow43import kotlinx.coroutines.launch4445sealed interface AdoptVaultUiState {46    data object Password : AdoptVaultUiState47    data object Pair : AdoptVaultUiState48    data object Connecting : AdoptVaultUiState49    /**50     * Source-initiator flow exposes [isSource] so the screen can phrase the51     * confirmation as "send vault" instead of "adopt/join vault". The SAS is52     * identical to the receiver flow; only the payload direction differs.53     */54    data class ConfirmSas(55        val sas: String,56        val isSource: Boolean = false,57        val peerName: String? = null,58    ) : AdoptVaultUiState59    data object Adopting : AdoptVaultUiState60    data class Complete(val sourceName: String) : AdoptVaultUiState61    data class Failed(val reason: String) : AdoptVaultUiState62}6364@HiltViewModel65class AdoptVaultViewModel @Inject constructor(66    private val receiver: RelayAdoptionReceiver,67    private val vault: VaultRepository,68    private val devices: PairedDeviceStore,69    private val relaySettings: RelaySettingsStore,70    private val backgroundSync: BackgroundSyncPreference,71    private val license: LicenseRepository,72    @ApplicationContext private val appContext: Context,73) : ViewModel() {74    /**75     * True when this device already has a vault. A populated device may refresh76     * a pairing only for that exact vault identity; it may not adopt a different77     * root key and silently merge two record sets.78     */79    val joining: Boolean = vault.hasVault()8081    private val _state = MutableStateFlow<AdoptVaultUiState>(82        initialAdoptVaultState(joining),83    )84    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8586    private var password: String? = null87    private var confirmation: CompletableDeferred<Boolean>? = null88    private var adoptionJob: Job? = null89    private var protocolError: String? = null9091    fun acceptPassword(value: String, repeated: String): Boolean {92        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {93            return false94        }95        password = value96        _state.value = AdoptVaultUiState.Pair97        return true98    }99100    fun adopt(invitation: String) {101        if (_state.value !is AdoptVaultUiState.Pair) return102        val parsed = parsePairingInvitation(invitation)103        if (parsed == null) {104            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")105            clearPassword()106            return107        }108        when (parsed.vaultStatus) {109            VaultStatus.POPULATED -> startReceiverFlow(invitation)110            VaultStatus.EMPTY -> startSourceFlow(invitation)111        }112    }113114    private fun startReceiverFlow(invitation: String) {115        _state.value = AdoptVaultUiState.Connecting116        protocolError = null117        adoptionJob = viewModelScope.launch {118            try {119                val adopted = receiver.adopt(120                    encodedInvitation = invitation,121                    deps = receiverDeps(),122                    confirm = { sas ->123                        val pending = CompletableDeferred<Boolean>()124                        confirmation = pending125                        _state.value = AdoptVaultUiState.ConfirmSas(126                            sas = sas,127                            isSource = false,128                        )129                        pending.await()130                    },131                )132                // The pair carries the source's relay, but the Sync card reads133                // this device's own setting — left alone it would say "Public134                // relay" on a phone dialling a private one. Peach's own URL135                // still lands on PUBLIC, so joining over the public relay is136                // not relabelled private.137                adopted.relayUrl?.let(relaySettings::setRelayUrl)138                _state.value = AdoptVaultUiState.Complete(139                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",140                )141            } catch (error: CancellationException) {142                throw error143            } catch (error: Throwable) {144                _state.value = AdoptVaultUiState.Failed(145                    protocolError ?: error.message ?: "adoption_failed",146                )147            } finally {148                confirmation = null149                clearPassword()150            }151        }152    }153154    /**155     * Source-initiator flow: the scanning invitation came from a vault-less156     * device, so the phone dials the relay, completes Noise, and sends the157     * root key over. Uses the same UI states as the receiver flow so the158     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]159     * distinguishes the role for the confirmation copy.160     */161    private fun startSourceFlow(invitation: String) {162        val master = vault.syncMasterKey()163        if (master == null) {164            _state.value = AdoptVaultUiState.Failed("vault_locked")165            clearPassword()166            return167        }168        when (val decision = relaySettings.relayInForce()) {169            is RelayDecision.Refuse -> {170                VaultCrypto.zero(master)171                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())172                clearPassword()173                return174            }175            is RelayDecision.Dial -> Unit176        }177        _state.value = AdoptVaultUiState.Connecting178        protocolError = null179180        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))181        val identity = buildLocalIdentity(appContext, master)182        VaultCrypto.zero(master)183        adoptionJob = viewModelScope.launch {184            try {185                val paired = receiver.grantFromInvitation(186                    encodedInvitation = invitation,187                    sourceDeps = sourceDeps(identity),188                    confirm = { sas, request ->189                        val pending = CompletableDeferred<Boolean>()190                        confirmation = pending191                        _state.value = AdoptVaultUiState.ConfirmSas(192                            sas = sas,193                            isSource = true,194                            peerName = request.deviceName.ifBlank { "New device" },195                        )196                        pending.await()197                    },198                )199                paired.relayUrl?.let(relaySettings::setRelayUrl)200                _state.value = AdoptVaultUiState.Complete(201                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",202                )203            } catch (error: CancellationException) {204                throw error205            } catch (error: Throwable) {206                _state.value = AdoptVaultUiState.Failed(207                    protocolError ?: error.message ?: "adoption_failed",208                )209            } finally {210                VaultCrypto.zero(keys.secretKey)211                VaultCrypto.zero(keys.publicKey)212                confirmation = null213                clearPassword()214            }215        }216    }217218    fun confirmSas(matches: Boolean) {219        val pending = confirmation ?: return220        confirmation = null221        if (matches) _state.value = AdoptVaultUiState.Adopting222        pending.complete(matches)223    }224225    fun restart() {226        adoptionJob?.cancel()227        adoptionJob = null228        confirmation?.complete(false)229        confirmation = null230        clearPassword()231        _state.value = initialAdoptVaultState(joining)232    }233234    private fun receiverDeps(): AdoptionReceiverDeps =235        object : AdoptionReceiverDeps {236            override val deviceName = getDeviceName(appContext)237            override val deviceType = DeviceType.ANDROID238            override fun generateDeviceId(): String = UUID.randomUUID().toString()239240            // RelayAdoptionReceiver replaces this with the SAS bound to its241            // completed Noise transcript.242            override suspend fun confirmSas(): Boolean = false243244            override suspend fun validateSource(source: AdoptionFrame.Source) {245                // First contact on a populated vault is allowed: a second246                // browser, a tablet, another phone — any device that holds the247                // same root key (the same recovery words) is the same vault.248                // The vault-id check in createVaultFromRootKey is the real249                // security boundary; this callback now only documents intent250                // for callers that want to layer their own policy on top.251                if (!joining) return252            }253254            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {255                // Same protocol seam, two safe outcomes: a fresh device builds256                // around the granted key; a populated device may only refresh a257                // pairing when it already has that exact vault identity.258                val result = if (joining) {259                    val incomingRoot = hexToPubkey(rootKeyHex)260                    val incomingVaultId = try {261                        computeVaultId(incomingRoot)262                    } finally {263                        VaultCrypto.zero(incomingRoot)264                    }265                    AdoptVaultResult.Success(266                        requireSameVaultForPopulatedRePair(267                            localVaultId = vault.syncVaultId(),268                            incomingVaultId = incomingVaultId,269                        ),270                    )271                } else {272                    val localPassword = password273                        ?: throw IllegalStateException("adoption_password_unavailable")274                    vault.createVaultFromRootKey(localPassword, rootKeyHex)275                }276                return when (result) {277                    is AdoptVaultResult.Success -> result.vaultId278                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)279                }280            }281282            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {283                val master = vault.syncMasterKey()284                    ?: throw IllegalStateException("adopted_vault_unavailable")285                return try {286                    buildLocalIdentity(appContext, master)287                } finally {288                    VaultCrypto.zero(master)289                }290            }291292            override suspend fun clearPairedDeviceRevocation(deviceId: String) {293                devices.clearRevocation(deviceId)294            }295296            override suspend fun upsertPairedDevice(device: PairedDevice) {297                devices.upsert(device)298            }299300            override suspend fun applyLicense(licenseKey: String) {301                if (!license.validateLicense(licenseKey)) {302                    throw IllegalStateException("license_transfer_failed")303                }304                license.storeLicense(licenseKey)305                if (!license.isProActive()) {306                    throw IllegalStateException("license_transfer_failed")307                }308            }309310            override suspend fun license(): String? =311                license.getLicenseKey()?.takeIf(license::validateLicense)312313            override fun onError(error: String) {314                protocolError = error315            }316317            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {318                val adapter = VaultSyncAdapter(319                    repo = vault,320                    ourDeviceId = getOrCreateDeviceId(appContext),321                    pairedDevices = devices,322                )323                adapter.applyEntries(entries)324                adapter.applyTombstones(tombstones)325            }326327            override suspend fun markSynced(deviceId: String) {328                devices.updateLastSync(deviceId)329            }330        }331332    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =333        object : AdoptionSourceDeps {334            override val sourceIdentity: LocalDeviceIdentity = identity335336            override suspend fun rootKeyHex(): String {337                val root = vault.syncMasterKey()338                    ?: throw IllegalStateException("vault_locked")339                return try {340                    root.joinToString("") { "%02x".format(it) }341                } finally {342                    VaultCrypto.zero(root)343                }344            }345346            override suspend fun vaultId(): String =347                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")348349            override suspend fun license(): String? =350                license.getLicenseKey()?.takeIf(license::validateLicense)351352            override suspend fun applyLicense(licenseKey: String) {353                if (!license.validateLicense(licenseKey)) {354                    throw IllegalStateException("license_transfer_failed")355                }356                license.storeLicense(licenseKey)357                if (!license.isProActive()) {358                    throw IllegalStateException("license_transfer_failed")359                }360            }361362            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true363364            override suspend fun upsertPairedDevice(device: PairedDevice) {365                devices.clearRevocation(device.deviceId)366                devices.upsert(device)367            }368369            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {370                val adapter = VaultSyncAdapter(371                    repo = vault,372                    ourDeviceId = getOrCreateDeviceId(appContext),373                    pairedDevices = devices,374                )375                val entryManifest = adapter.buildEntryManifest()376                val entryIds = entryManifest.keys.toList()377                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()378                val tombstones = adapter.buildTombstoneManifest()379                return entries to tombstones380            }381382            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {383                val adapter = VaultSyncAdapter(384                    repo = vault,385                    ourDeviceId = getOrCreateDeviceId(appContext),386                    pairedDevices = devices,387                )388                return adapter.buildOutgoingDeviceManifest()389            }390391            override suspend fun markSynced(deviceId: String) {392                devices.updateLastSync(deviceId)393            }394        }395396    fun setBackgroundSync(enabled: Boolean) {397        backgroundSync.setEnabled(enabled)398        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)399    }400401    private fun clearPassword() {402        password = null403    }404405    override fun onCleared() {406        adoptionJob?.cancel()407        confirmation?.complete(false)408        clearPassword()409        super.onCleared()410    }411}412413internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =414    if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password415416/**417 * Pairing is not a merge tool. A populated destination may re-pair only when418 * both sides already derive the same vault id. Different ids must stop before419 * recovery words, entries, revocations, or paired-device state are changed.420 */421internal fun requireSameVaultForPopulatedRePair(422    localVaultId: String?,423    incomingVaultId: String,424): String {425    if (localVaultId == null) throw IllegalStateException("vault_locked")426    if (localVaultId != incomingVaultId) {427        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")428    }429    return localVaultId430}