AdoptVaultViewModel.kt

19.0 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.PendingVouchedDeviceStore17import com.peachpasswords.sync.SignedDeviceManifest18import com.peachpasswords.sync.VaultSyncAdapter19import com.peachpasswords.sync.PeachSyncService20import com.peachpasswords.sync.RelayAdoptionReceiver21import com.peachpasswords.sync.RelaySettingsStore22import com.peachpasswords.sync.RelayDecision23import com.peachpasswords.sync.VaultStatus24import com.peachpasswords.sync.buildLocalIdentity25import com.peachpasswords.sync.computeVaultId26import com.peachpasswords.sync.deriveLocalSyncKeypair27import com.peachpasswords.sync.getDeviceName28import com.peachpasswords.sync.getOrCreateDeviceId29import com.peachpasswords.sync.hexToPubkey30import com.peachpasswords.sync.parsePairingInvitation31import com.peachpasswords.ui.security.scorePassword32import com.peachpasswords.vault.AdoptVaultResult33import com.peachpasswords.vault.VaultCrypto34import com.peachpasswords.vault.VaultRepository35import dagger.hilt.android.lifecycle.HiltViewModel36import dagger.hilt.android.qualifiers.ApplicationContext37import java.util.UUID38import javax.inject.Inject39import kotlinx.coroutines.CompletableDeferred40import kotlinx.coroutines.CancellationException41import kotlinx.coroutines.Job42import kotlinx.coroutines.flow.MutableStateFlow43import kotlinx.coroutines.flow.StateFlow44import kotlinx.coroutines.flow.asStateFlow45import kotlinx.coroutines.launch4647sealed interface AdoptVaultUiState {48    data object Password : AdoptVaultUiState49    data object Pair : AdoptVaultUiState50    data object Connecting : AdoptVaultUiState51    /**52     * Source-initiator flow exposes [isSource] so the screen can phrase the53     * confirmation as "send vault" instead of "adopt/join vault". The SAS is54     * identical to the receiver flow; only the payload direction differs.55     */56    data class ConfirmSas(57        val sas: String,58        val isSource: Boolean = false,59        val peerName: String? = null,60    ) : AdoptVaultUiState61    data object Adopting : AdoptVaultUiState62    data class Complete(val sourceName: String) : AdoptVaultUiState63    data class Failed(val reason: String) : AdoptVaultUiState64}6566@HiltViewModel67class AdoptVaultViewModel @Inject constructor(68    private val receiver: RelayAdoptionReceiver,69    private val vault: VaultRepository,70    private val devices: PairedDeviceStore,71    private val relaySettings: RelaySettingsStore,72    private val backgroundSync: BackgroundSyncPreference,73    private val license: LicenseRepository,74    private val pendingVouchedStore: PendingVouchedDeviceStore,75    @ApplicationContext private val appContext: Context,76) : ViewModel() {77    /**78     * True when this device already has a vault. A populated device may refresh79     * a pairing only for that exact vault identity; it may not adopt a different80     * root key and silently merge two record sets.81     */82    val joining: Boolean = vault.hasVault()8384    private val _state = MutableStateFlow<AdoptVaultUiState>(85        initialAdoptVaultState(joining),86    )87    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()8889    private var password: String? = null90    private var confirmation: CompletableDeferred<Boolean>? = null91    private var adoptionJob: Job? = null92    private var protocolError: String? = null9394    fun acceptPassword(value: String, repeated: String): Boolean {95        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {96            return false97        }98        password = value99        _state.value = AdoptVaultUiState.Pair100        return true101    }102103    fun adopt(invitation: String) {104        if (_state.value !is AdoptVaultUiState.Pair) return105        val parsed = parsePairingInvitation(invitation)106        if (parsed == null) {107            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")108            clearPassword()109            return110        }111        when (parsed.vaultStatus) {112            VaultStatus.POPULATED -> startReceiverFlow(invitation)113            VaultStatus.EMPTY -> startSourceFlow(invitation)114        }115    }116117    private fun startReceiverFlow(invitation: String) {118        _state.value = AdoptVaultUiState.Connecting119        protocolError = null120        adoptionJob = viewModelScope.launch {121            try {122                val adopted = receiver.adopt(123                    encodedInvitation = invitation,124                    deps = receiverDeps(),125                    confirm = { sas ->126                        val pending = CompletableDeferred<Boolean>()127                        confirmation = pending128                        _state.value = AdoptVaultUiState.ConfirmSas(129                            sas = sas,130                            isSource = false,131                        )132                        pending.await()133                    },134                )135                // The pair carries the source's relay, but the Sync card reads136                // this device's own setting — left alone it would say "Public137                // relay" on a phone dialling a private one. Peach's own URL138                // still lands on PUBLIC, so joining over the public relay is139                // not relabelled private.140                adopted.relayUrl?.let(relaySettings::setRelayUrl)141                _state.value = AdoptVaultUiState.Complete(142                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",143                )144            } catch (error: CancellationException) {145                throw error146            } catch (error: Throwable) {147                _state.value = AdoptVaultUiState.Failed(148                    protocolError ?: error.message ?: "adoption_failed",149                )150            } finally {151                confirmation = null152                clearPassword()153            }154        }155    }156157    /**158     * Source-initiator flow: the scanning invitation came from a vault-less159     * device, so the phone dials the relay, completes Noise, and sends the160     * root key over. Uses the same UI states as the receiver flow so the161     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]162     * distinguishes the role for the confirmation copy.163     */164    private fun startSourceFlow(invitation: String) {165        val master = vault.syncMasterKey()166        if (master == null) {167            _state.value = AdoptVaultUiState.Failed("vault_locked")168            clearPassword()169            return170        }171        when (val decision = relaySettings.relayInForce()) {172            is RelayDecision.Refuse -> {173                VaultCrypto.zero(master)174                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())175                clearPassword()176                return177            }178            is RelayDecision.Dial -> Unit179        }180        _state.value = AdoptVaultUiState.Connecting181        protocolError = null182183        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))184        val identity = buildLocalIdentity(appContext, master)185        VaultCrypto.zero(master)186        adoptionJob = viewModelScope.launch {187            try {188                val paired = receiver.grantFromInvitation(189                    encodedInvitation = invitation,190                    sourceDeps = sourceDeps(identity),191                    confirm = { sas, request ->192                        val pending = CompletableDeferred<Boolean>()193                        confirmation = pending194                        _state.value = AdoptVaultUiState.ConfirmSas(195                            sas = sas,196                            isSource = true,197                            peerName = request.deviceName.ifBlank { "New device" },198                        )199                        pending.await()200                    },201                )202                paired.relayUrl?.let(relaySettings::setRelayUrl)203                _state.value = AdoptVaultUiState.Complete(204                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",205                )206            } catch (error: CancellationException) {207                throw error208            } catch (error: Throwable) {209                _state.value = AdoptVaultUiState.Failed(210                    protocolError ?: error.message ?: "adoption_failed",211                )212            } finally {213                VaultCrypto.zero(keys.secretKey)214                VaultCrypto.zero(keys.publicKey)215                confirmation = null216                clearPassword()217            }218        }219    }220221    fun confirmSas(matches: Boolean) {222        val pending = confirmation ?: return223        confirmation = null224        if (matches) _state.value = AdoptVaultUiState.Adopting225        pending.complete(matches)226    }227228    fun restart() {229        adoptionJob?.cancel()230        adoptionJob = null231        confirmation?.complete(false)232        confirmation = null233        clearPassword()234        _state.value = initialAdoptVaultState(joining)235    }236237    private fun receiverDeps(): AdoptionReceiverDeps =238        object : AdoptionReceiverDeps {239            override val deviceName = getDeviceName(appContext)240            override val deviceType = DeviceType.ANDROID241            override fun generateDeviceId(): String = UUID.randomUUID().toString()242243            // RelayAdoptionReceiver replaces this with the SAS bound to its244            // completed Noise transcript.245            override suspend fun confirmSas(): Boolean = false246247            override suspend fun validateSource(source: AdoptionFrame.Source) {248                // First contact on a populated vault is allowed: a second249                // browser, a tablet, another phone — any device that holds the250                // same root key (the same recovery words) is the same vault.251                // The vault-id check in createVaultFromRootKey is the real252                // security boundary; this callback now only documents intent253                // for callers that want to layer their own policy on top.254                if (!joining) return255            }256257            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {258                // Same protocol seam, two safe outcomes: a fresh device builds259                // around the granted key; a populated device may only refresh a260                // pairing when it already has that exact vault identity.261                val result = if (joining) {262                    val incomingRoot = hexToPubkey(rootKeyHex)263                    val incomingVaultId = try {264                        computeVaultId(incomingRoot)265                    } finally {266                        VaultCrypto.zero(incomingRoot)267                    }268                    AdoptVaultResult.Success(269                        requireSameVaultForPopulatedRePair(270                            localVaultId = vault.syncVaultId(),271                            incomingVaultId = incomingVaultId,272                        ),273                    )274                } else {275                    val localPassword = password276                        ?: throw IllegalStateException("adoption_password_unavailable")277                    vault.createVaultFromRootKey(localPassword, rootKeyHex)278                }279                return when (result) {280                    is AdoptVaultResult.Success -> result.vaultId281                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)282                }283            }284285            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {286                val master = vault.syncMasterKey()287                    ?: throw IllegalStateException("adopted_vault_unavailable")288                return try {289                    buildLocalIdentity(appContext, master)290                } finally {291                    VaultCrypto.zero(master)292                }293            }294295            override suspend fun clearPairedDeviceRevocation(deviceId: String) {296                devices.clearRevocation(deviceId)297            }298299            override suspend fun upsertPairedDevice(device: PairedDevice) {300                devices.upsert(device)301            }302303            override suspend fun applyLicense(licenseKey: String) {304                if (!license.validateLicense(licenseKey)) {305                    throw IllegalStateException("license_transfer_failed")306                }307                license.storeLicense(licenseKey)308                if (!license.isProActive()) {309                    throw IllegalStateException("license_transfer_failed")310                }311            }312313            override suspend fun license(): String? =314                license.getLicenseKey()?.takeIf(license::validateLicense)315316            override fun onError(error: String) {317                protocolError = error318            }319320            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {321                val adapter = VaultSyncAdapter(322                    repo = vault,323                    ourDeviceId = getOrCreateDeviceId(appContext),324                    pairedDevices = devices,325                )326                adapter.applyEntries(entries)327                adapter.applyTombstones(tombstones)328                if (!duressSlot.isNullOrBlank()) {329                    try {330                        val ok = vault.installAdoptedDuressSlot(duressSlot)331                        if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")332                    } catch (e: Exception) {333                        Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)334                    }335                }336            }337338            override suspend fun applyIncomingDeviceManifest(339                manifest: SignedDeviceManifest,340                authenticatedRemoteStaticPublicKey: ByteArray,341            ) {342                // Mesh graduation: the source's signed device graph promotes343                // the receiver into the full mesh pool. The receiver must344                // already have the source as a paired device (see applySnapshot345                // above which calls upsertPairedDevice via the Grant path),346                // so applyIncomingDeviceManifest can resolve the issuer by its347                // transport static key and verify the manifest signature.348                val adapter = VaultSyncAdapter(349                    repo = vault,350                    ourDeviceId = getOrCreateDeviceId(appContext),351                    pairedDevices = devices,352                    pendingVouched = pendingVouchedStore,353                )354                adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)355            }356357            override suspend fun markSynced(deviceId: String) {358                devices.updateLastSync(deviceId)359            }360        }361362    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =363        object : AdoptionSourceDeps {364            override val sourceIdentity: LocalDeviceIdentity = identity365366            override suspend fun rootKeyHex(): String {367                val root = vault.syncMasterKey()368                    ?: throw IllegalStateException("vault_locked")369                return try {370                    root.joinToString("") { "%02x".format(it) }371                } finally {372                    VaultCrypto.zero(root)373                }374            }375376            override suspend fun vaultId(): String =377                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")378379            override suspend fun license(): String? =380                license.getLicenseKey()?.takeIf(license::validateLicense)381382            override suspend fun applyLicense(licenseKey: String) {383                if (!license.validateLicense(licenseKey)) {384                    throw IllegalStateException("license_transfer_failed")385                }386                license.storeLicense(licenseKey)387                if (!license.isProActive()) {388                    throw IllegalStateException("license_transfer_failed")389                }390            }391392            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true393394            override suspend fun upsertPairedDevice(device: PairedDevice) {395                devices.clearRevocation(device.deviceId)396                devices.upsert(device)397            }398399            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {400                val adapter = VaultSyncAdapter(401                    repo = vault,402                    ourDeviceId = getOrCreateDeviceId(appContext),403                    pairedDevices = devices,404                )405                val entryManifest = adapter.buildEntryManifest()406                val entryIds = entryManifest.keys.toList()407                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()408                val tombstones = adapter.buildTombstoneManifest()409                return entries to tombstones410            }411412            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {413                val adapter = VaultSyncAdapter(414                    repo = vault,415                    ourDeviceId = getOrCreateDeviceId(appContext),416                    pairedDevices = devices,417                )418                return adapter.buildOutgoingDeviceManifest()419            }420421            override suspend fun markSynced(deviceId: String) {422                devices.updateLastSync(deviceId)423            }424425            override suspend fun duressSlotJson(): String? =426                vault.exportDuressSlotForAdoption()427        }428429    fun setBackgroundSync(enabled: Boolean) {430        backgroundSync.setEnabled(enabled)431        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)432    }433434    private fun clearPassword() {435        password = null436    }437438    override fun onCleared() {439        adoptionJob?.cancel()440        confirmation?.complete(false)441        clearPassword()442        super.onCleared()443    }444}445446internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =447    if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password448449/**450 * Pairing is not a merge tool. A populated destination may re-pair only when451 * both sides already derive the same vault id. Different ids must stop before452 * recovery words, entries, revocations, or paired-device state are changed.453 */454internal fun requireSameVaultForPopulatedRePair(455    localVaultId: String?,456    incomingVaultId: String,457): String {458    if (localVaultId == null) throw IllegalStateException("vault_locked")459    if (localVaultId != incomingVaultId) {460        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")461    }462    return localVaultId463}