AdoptVaultViewModel.kt

17.8 KB · raw

1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import androidx.lifecycle.ViewModel6import androidx.lifecycle.viewModelScope7import com.peachpasswords.license.LicenseRepository8import com.peachpasswords.sync.AdoptionReceiverDeps9import com.peachpasswords.sync.AdoptionSourceDeps10import com.peachpasswords.sync.BackgroundSyncPreference11import com.peachpasswords.sync.DeviceType12import com.peachpasswords.sync.AdoptionFrame13import com.peachpasswords.sync.LocalDeviceIdentity14import com.peachpasswords.sync.PairedDevice15import com.peachpasswords.sync.PairedDeviceStore16import com.peachpasswords.sync.SignedDeviceManifest17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.PeachSyncService19import com.peachpasswords.sync.RelayAdoptionReceiver20import com.peachpasswords.sync.RelaySettingsStore21import com.peachpasswords.sync.RelayDecision22import com.peachpasswords.sync.VaultStatus23import com.peachpasswords.sync.buildLocalIdentity24import com.peachpasswords.sync.computeVaultId25import com.peachpasswords.sync.deriveLocalSyncKeypair26import com.peachpasswords.sync.getDeviceName27import com.peachpasswords.sync.getOrCreateDeviceId28import com.peachpasswords.sync.hexToPubkey29import com.peachpasswords.sync.parsePairingInvitation30import com.peachpasswords.ui.security.scorePassword31import com.peachpasswords.vault.AdoptVaultResult32import com.peachpasswords.vault.VaultCrypto33import com.peachpasswords.vault.VaultRepository34import dagger.hilt.android.lifecycle.HiltViewModel35import dagger.hilt.android.qualifiers.ApplicationContext36import java.util.UUID37import javax.inject.Inject38import kotlinx.coroutines.CompletableDeferred39import kotlinx.coroutines.CancellationException40import kotlinx.coroutines.Job41import kotlinx.coroutines.flow.MutableStateFlow42import kotlinx.coroutines.flow.StateFlow43import kotlinx.coroutines.flow.asStateFlow44import kotlinx.coroutines.launch4546sealed interface AdoptVaultUiState {47    data object Password : AdoptVaultUiState48    data object Pair : AdoptVaultUiState49    data object Connecting : AdoptVaultUiState50    /**51     * Source-initiator flow exposes [isSource] so the screen can phrase the52     * confirmation as "send vault" instead of "adopt/join vault". The SAS is53     * identical to the receiver flow; only the payload direction differs.54     */55    data class ConfirmSas(56        val sas: String,57        val isSource: Boolean = false,58        val peerName: String? = null,59    ) : AdoptVaultUiState60    data object Adopting : AdoptVaultUiState61    data class Complete(val sourceName: String) : AdoptVaultUiState62    data class Failed(val reason: String) : AdoptVaultUiState63}6465@HiltViewModel66class AdoptVaultViewModel @Inject constructor(67    private val receiver: RelayAdoptionReceiver,68    private val vault: VaultRepository,69    private val devices: PairedDeviceStore,70    private val relaySettings: RelaySettingsStore,71    private val backgroundSync: BackgroundSyncPreference,72    private val license: LicenseRepository,73    @ApplicationContext private val appContext: Context,74) : ViewModel() {75    /**76     * True when this device already has a vault. A populated device may refresh77     * a pairing only for that exact vault identity; it may not adopt a different78     * root key and silently merge two record sets.79     */80    val joining: Boolean = vault.hasVault()8182    private val _state = MutableStateFlow<AdoptVaultUiState>(83        initialAdoptVaultState(joining),84    )85    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8687    private var password: String? = null88    private var confirmation: CompletableDeferred<Boolean>? = null89    private var adoptionJob: Job? = null90    private var protocolError: String? = null9192    fun acceptPassword(value: String, repeated: String): Boolean {93        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {94            return false95        }96        password = value97        _state.value = AdoptVaultUiState.Pair98        return true99    }100101    fun adopt(invitation: String) {102        if (_state.value !is AdoptVaultUiState.Pair) return103        val parsed = parsePairingInvitation(invitation)104        if (parsed == null) {105            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")106            clearPassword()107            return108        }109        when (parsed.vaultStatus) {110            VaultStatus.POPULATED -> startReceiverFlow(invitation)111            VaultStatus.EMPTY -> startSourceFlow(invitation)112        }113    }114115    private fun startReceiverFlow(invitation: String) {116        _state.value = AdoptVaultUiState.Connecting117        protocolError = null118        adoptionJob = viewModelScope.launch {119            try {120                val adopted = receiver.adopt(121                    encodedInvitation = invitation,122                    deps = receiverDeps(),123                    confirm = { sas ->124                        val pending = CompletableDeferred<Boolean>()125                        confirmation = pending126                        _state.value = AdoptVaultUiState.ConfirmSas(127                            sas = sas,128                            isSource = false,129                        )130                        pending.await()131                    },132                )133                // The pair carries the source's relay, but the Sync card reads134                // this device's own setting — left alone it would say "Public135                // relay" on a phone dialling a private one. Peach's own URL136                // still lands on PUBLIC, so joining over the public relay is137                // not relabelled private.138                adopted.relayUrl?.let(relaySettings::setRelayUrl)139                _state.value = AdoptVaultUiState.Complete(140                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",141                )142            } catch (error: CancellationException) {143                throw error144            } catch (error: Throwable) {145                _state.value = AdoptVaultUiState.Failed(146                    protocolError ?: error.message ?: "adoption_failed",147                )148            } finally {149                confirmation = null150                clearPassword()151            }152        }153    }154155    /**156     * Source-initiator flow: the scanning invitation came from a vault-less157     * device, so the phone dials the relay, completes Noise, and sends the158     * root key over. Uses the same UI states as the receiver flow so the159     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]160     * distinguishes the role for the confirmation copy.161     */162    private fun startSourceFlow(invitation: String) {163        val master = vault.syncMasterKey()164        if (master == null) {165            _state.value = AdoptVaultUiState.Failed("vault_locked")166            clearPassword()167            return168        }169        when (val decision = relaySettings.relayInForce()) {170            is RelayDecision.Refuse -> {171                VaultCrypto.zero(master)172                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())173                clearPassword()174                return175            }176            is RelayDecision.Dial -> Unit177        }178        _state.value = AdoptVaultUiState.Connecting179        protocolError = null180181        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))182        val identity = buildLocalIdentity(appContext, master)183        VaultCrypto.zero(master)184        adoptionJob = viewModelScope.launch {185            try {186                val paired = receiver.grantFromInvitation(187                    encodedInvitation = invitation,188                    sourceDeps = sourceDeps(identity),189                    confirm = { sas, request ->190                        val pending = CompletableDeferred<Boolean>()191                        confirmation = pending192                        _state.value = AdoptVaultUiState.ConfirmSas(193                            sas = sas,194                            isSource = true,195                            peerName = request.deviceName.ifBlank { "New device" },196                        )197                        pending.await()198                    },199                )200                paired.relayUrl?.let(relaySettings::setRelayUrl)201                _state.value = AdoptVaultUiState.Complete(202                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",203                )204            } catch (error: CancellationException) {205                throw error206            } catch (error: Throwable) {207                _state.value = AdoptVaultUiState.Failed(208                    protocolError ?: error.message ?: "adoption_failed",209                )210            } finally {211                VaultCrypto.zero(keys.secretKey)212                VaultCrypto.zero(keys.publicKey)213                confirmation = null214                clearPassword()215            }216        }217    }218219    fun confirmSas(matches: Boolean) {220        val pending = confirmation ?: return221        confirmation = null222        if (matches) _state.value = AdoptVaultUiState.Adopting223        pending.complete(matches)224    }225226    fun restart() {227        adoptionJob?.cancel()228        adoptionJob = null229        confirmation?.complete(false)230        confirmation = null231        clearPassword()232        _state.value = initialAdoptVaultState(joining)233    }234235    private fun receiverDeps(): AdoptionReceiverDeps =236        object : AdoptionReceiverDeps {237            override val deviceName = getDeviceName(appContext)238            override val deviceType = DeviceType.ANDROID239            override fun generateDeviceId(): String = UUID.randomUUID().toString()240241            // RelayAdoptionReceiver replaces this with the SAS bound to its242            // completed Noise transcript.243            override suspend fun confirmSas(): Boolean = false244245            override suspend fun validateSource(source: AdoptionFrame.Source) {246                // First contact on a populated vault is allowed: a second247                // browser, a tablet, another phone — any device that holds the248                // same root key (the same recovery words) is the same vault.249                // The vault-id check in createVaultFromRootKey is the real250                // security boundary; this callback now only documents intent251                // for callers that want to layer their own policy on top.252                if (!joining) return253            }254255            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {256                // Same protocol seam, two safe outcomes: a fresh device builds257                // around the granted key; a populated device may only refresh a258                // pairing when it already has that exact vault identity.259                val result = if (joining) {260                    val incomingRoot = hexToPubkey(rootKeyHex)261                    val incomingVaultId = try {262                        computeVaultId(incomingRoot)263                    } finally {264                        VaultCrypto.zero(incomingRoot)265                    }266                    AdoptVaultResult.Success(267                        requireSameVaultForPopulatedRePair(268                            localVaultId = vault.syncVaultId(),269                            incomingVaultId = incomingVaultId,270                        ),271                    )272                } else {273                    val localPassword = password274                        ?: throw IllegalStateException("adoption_password_unavailable")275                    vault.createVaultFromRootKey(localPassword, rootKeyHex)276                }277                return when (result) {278                    is AdoptVaultResult.Success -> result.vaultId279                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)280                }281            }282283            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {284                val master = vault.syncMasterKey()285                    ?: throw IllegalStateException("adopted_vault_unavailable")286                return try {287                    buildLocalIdentity(appContext, master)288                } finally {289                    VaultCrypto.zero(master)290                }291            }292293            override suspend fun clearPairedDeviceRevocation(deviceId: String) {294                devices.clearRevocation(deviceId)295            }296297            override suspend fun upsertPairedDevice(device: PairedDevice) {298                devices.upsert(device)299            }300301            override suspend fun applyLicense(licenseKey: String) {302                if (!license.validateLicense(licenseKey)) {303                    throw IllegalStateException("license_transfer_failed")304                }305                license.storeLicense(licenseKey)306                if (!license.isProActive()) {307                    throw IllegalStateException("license_transfer_failed")308                }309            }310311            override suspend fun license(): String? =312                license.getLicenseKey()?.takeIf(license::validateLicense)313314            override fun onError(error: String) {315                protocolError = error316            }317318            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {319                val adapter = VaultSyncAdapter(320                    repo = vault,321                    ourDeviceId = getOrCreateDeviceId(appContext),322                    pairedDevices = devices,323                )324                adapter.applyEntries(entries)325                adapter.applyTombstones(tombstones)326                if (!duressSlot.isNullOrBlank()) {327                    try {328                        val ok = vault.installAdoptedDuressSlot(duressSlot)329                        if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")330                    } catch (e: Exception) {331                        Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)332                    }333                }334            }335336            override suspend fun markSynced(deviceId: String) {337                devices.updateLastSync(deviceId)338            }339        }340341    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =342        object : AdoptionSourceDeps {343            override val sourceIdentity: LocalDeviceIdentity = identity344345            override suspend fun rootKeyHex(): String {346                val root = vault.syncMasterKey()347                    ?: throw IllegalStateException("vault_locked")348                return try {349                    root.joinToString("") { "%02x".format(it) }350                } finally {351                    VaultCrypto.zero(root)352                }353            }354355            override suspend fun vaultId(): String =356                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")357358            override suspend fun license(): String? =359                license.getLicenseKey()?.takeIf(license::validateLicense)360361            override suspend fun applyLicense(licenseKey: String) {362                if (!license.validateLicense(licenseKey)) {363                    throw IllegalStateException("license_transfer_failed")364                }365                license.storeLicense(licenseKey)366                if (!license.isProActive()) {367                    throw IllegalStateException("license_transfer_failed")368                }369            }370371            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true372373            override suspend fun upsertPairedDevice(device: PairedDevice) {374                devices.clearRevocation(device.deviceId)375                devices.upsert(device)376            }377378            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {379                val adapter = VaultSyncAdapter(380                    repo = vault,381                    ourDeviceId = getOrCreateDeviceId(appContext),382                    pairedDevices = devices,383                )384                val entryManifest = adapter.buildEntryManifest()385                val entryIds = entryManifest.keys.toList()386                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()387                val tombstones = adapter.buildTombstoneManifest()388                return entries to tombstones389            }390391            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {392                val adapter = VaultSyncAdapter(393                    repo = vault,394                    ourDeviceId = getOrCreateDeviceId(appContext),395                    pairedDevices = devices,396                )397                return adapter.buildOutgoingDeviceManifest()398            }399400            override suspend fun markSynced(deviceId: String) {401                devices.updateLastSync(deviceId)402            }403404            override suspend fun duressSlotJson(): String? =405                vault.exportDuressSlotForAdoption()406        }407408    fun setBackgroundSync(enabled: Boolean) {409        backgroundSync.setEnabled(enabled)410        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)411    }412413    private fun clearPassword() {414        password = null415    }416417    override fun onCleared() {418        adoptionJob?.cancel()419        confirmation?.complete(false)420        clearPassword()421        super.onCleared()422    }423}424425internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =426    if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password427428/**429 * Pairing is not a merge tool. A populated destination may re-pair only when430 * both sides already derive the same vault id. Different ids must stop before431 * recovery words, entries, revocations, or paired-device state are changed.432 */433internal fun requireSameVaultForPopulatedRePair(434    localVaultId: String?,435    incomingVaultId: String,436): String {437    if (localVaultId == null) throw IllegalStateException("vault_locked")438    if (localVaultId != incomingVaultId) {439        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")440    }441    return localVaultId442}