AdoptVaultViewModel.kt

10.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.DeviceType10import com.peachpasswords.sync.AdoptionFrame11import com.peachpasswords.sync.LocalDeviceIdentity12import com.peachpasswords.sync.PairedDevice13import com.peachpasswords.sync.PairedDeviceStore14import com.peachpasswords.sync.PeachSyncService15import com.peachpasswords.sync.RelayAdoptionReceiver16import com.peachpasswords.sync.RelaySettingsStore17import com.peachpasswords.sync.buildLocalIdentity18import com.peachpasswords.sync.computeVaultId19import com.peachpasswords.sync.getDeviceName20import com.peachpasswords.sync.hexToPubkey21import com.peachpasswords.sync.parsePairingInvitation22import com.peachpasswords.ui.security.scorePassword23import com.peachpasswords.vault.AdoptVaultResult24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import java.util.UUID29import javax.inject.Inject30import kotlinx.coroutines.CompletableDeferred31import kotlinx.coroutines.CancellationException32import kotlinx.coroutines.Job33import kotlinx.coroutines.flow.MutableStateFlow34import kotlinx.coroutines.flow.StateFlow35import kotlinx.coroutines.flow.asStateFlow36import kotlinx.coroutines.launch3738sealed interface AdoptVaultUiState {39    data object Password : AdoptVaultUiState40    /**41     * Existing-vault mode only. Pairing is allowed only when the incoming root42     * key identifies this same vault; Peach refuses to combine populated43     * vaults because separately imported copies can otherwise nearly double.44     */45    data object Disclosure : AdoptVaultUiState46    data object Pair : AdoptVaultUiState47    data object Connecting : AdoptVaultUiState48    data class ConfirmSas(val sas: String) : AdoptVaultUiState49    data object Adopting : AdoptVaultUiState50    data class Complete(val sourceName: String) : AdoptVaultUiState51    data class Failed(val reason: String) : AdoptVaultUiState52}5354@HiltViewModel55class AdoptVaultViewModel @Inject constructor(56    private val receiver: RelayAdoptionReceiver,57    private val vault: VaultRepository,58    private val devices: PairedDeviceStore,59    private val relaySettings: RelaySettingsStore,60    private val backgroundSync: BackgroundSyncPreference,61    private val license: LicenseRepository,62    @ApplicationContext private val appContext: Context,63) : ViewModel() {64    /**65     * True when this device already has a vault. A populated device may refresh66     * a pairing only for that exact vault identity; it may not adopt a different67     * root key and silently merge two record sets.68     */69    val joining: Boolean = vault.hasVault()7071    private val _state = MutableStateFlow<AdoptVaultUiState>(72        if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password,73    )74    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()7576    private var password: String? = null77    private var confirmation: CompletableDeferred<Boolean>? = null78    private var adoptionJob: Job? = null79    private var protocolError: String? = null8081    fun acceptPassword(value: String, repeated: String): Boolean {82        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {83            return false84        }85        password = value86        _state.value = AdoptVaultUiState.Pair87        return true88    }8990    /** The user has read what joining does to this device's recovery words. */91    fun acceptDisclosure() {92        if (_state.value is AdoptVaultUiState.Disclosure) {93            _state.value = AdoptVaultUiState.Pair94        }95    }9697    fun adopt(invitation: String) {98        if (_state.value !is AdoptVaultUiState.Pair) return99        if (parsePairingInvitation(invitation) == null) {100            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")101            clearPassword()102            return103        }104        _state.value = AdoptVaultUiState.Connecting105        protocolError = null106        adoptionJob = viewModelScope.launch {107            try {108                val adopted = receiver.adopt(109                    encodedInvitation = invitation,110                    deps = receiverDeps(),111                    confirm = { sas ->112                        val pending = CompletableDeferred<Boolean>()113                        confirmation = pending114                        _state.value = AdoptVaultUiState.ConfirmSas(sas)115                        pending.await()116                    },117                )118                // The pair carries the source's relay, but the Sync card reads119                // this device's own setting — left alone it would say "Public120                // relay" on a phone dialling a private one. Peach's own URL121                // still lands on PUBLIC, so joining over the public relay is122                // not relabelled private.123                adopted.relayUrl?.let(relaySettings::setRelayUrl)124                _state.value = AdoptVaultUiState.Complete(125                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",126                )127            } catch (error: CancellationException) {128                throw error129            } catch (error: Throwable) {130                _state.value = AdoptVaultUiState.Failed(131                    protocolError ?: error.message ?: "adoption_failed",132                )133            } finally {134                confirmation = null135                clearPassword()136            }137        }138    }139140    fun confirmSas(matches: Boolean) {141        val pending = confirmation ?: return142        confirmation = null143        if (matches) _state.value = AdoptVaultUiState.Adopting144        pending.complete(matches)145    }146147    fun restart() {148        adoptionJob?.cancel()149        adoptionJob = null150        confirmation?.complete(false)151        confirmation = null152        clearPassword()153        _state.value =154            if (joining) AdoptVaultUiState.Disclosure else AdoptVaultUiState.Password155    }156157    private fun receiverDeps(): AdoptionReceiverDeps =158        object : AdoptionReceiverDeps {159            override val deviceName = getDeviceName(appContext)160            override val deviceType = DeviceType.ANDROID161            override fun generateDeviceId(): String = UUID.randomUUID().toString()162163            // RelayAdoptionReceiver replaces this with the SAS bound to its164            // completed Noise transcript.165            override suspend fun confirmSas(): Boolean = false166167            override suspend fun validateSource(source: AdoptionFrame.Source) {168                // First contact on a populated vault is allowed: a second169                // browser, a tablet, another phone — any device that holds the170                // same root key (the same recovery words) is the same vault.171                // The vault-id check in createVaultFromRootKey is the real172                // security boundary; this callback now only documents intent173                // for callers that want to layer their own policy on top.174                if (!joining) return175            }176177            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {178                // Same protocol seam, two safe outcomes: a fresh device builds179                // around the granted key; a populated device may only refresh a180                // pairing when it already has that exact vault identity.181                val result = if (joining) {182                    val incomingRoot = hexToPubkey(rootKeyHex)183                    val incomingVaultId = try {184                        computeVaultId(incomingRoot)185                    } finally {186                        VaultCrypto.zero(incomingRoot)187                    }188                    AdoptVaultResult.Success(189                        requireSameVaultForPopulatedRePair(190                            localVaultId = vault.syncVaultId(),191                            incomingVaultId = incomingVaultId,192                        ),193                    )194                } else {195                    val localPassword = password196                        ?: throw IllegalStateException("adoption_password_unavailable")197                    vault.createVaultFromRootKey(localPassword, rootKeyHex)198                }199                return when (result) {200                    is AdoptVaultResult.Success -> result.vaultId201                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)202                }203            }204205            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {206                val master = vault.syncMasterKey()207                    ?: throw IllegalStateException("adopted_vault_unavailable")208                return try {209                    buildLocalIdentity(appContext, master)210                } finally {211                    VaultCrypto.zero(master)212                }213            }214215            override suspend fun clearPairedDeviceRevocation(deviceId: String) {216                devices.clearRevocation(deviceId)217            }218219            override suspend fun upsertPairedDevice(device: PairedDevice) {220                devices.upsert(device)221            }222223            override suspend fun applyLicense(licenseKey: String) {224                if (license.validateLicense(licenseKey)) {225                    license.storeLicense(licenseKey)226                }227            }228229            override fun onError(error: String) {230                protocolError = error231            }232        }233234    fun setBackgroundSync(enabled: Boolean) {235        backgroundSync.setEnabled(enabled)236        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)237    }238239    private fun clearPassword() {240        password = null241    }242243    override fun onCleared() {244        adoptionJob?.cancel()245        confirmation?.complete(false)246        clearPassword()247        super.onCleared()248    }249}250251/**252 * Pairing is not a merge tool. A populated destination may re-pair only when253 * both sides already derive the same vault id. Different ids must stop before254 * recovery words, entries, revocations, or paired-device state are changed.255 */256internal fun requireSameVaultForPopulatedRePair(257    localVaultId: String?,258    incomingVaultId: String,259): String {260    if (localVaultId == null) throw IllegalStateException("vault_locked")261    if (localVaultId != incomingVaultId) {262        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")263    }264    return localVaultId265}