AdoptVaultViewModel.kt

24.2 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.AdoptionPairState9import com.peachpasswords.sync.AdoptionReceiverDeps10import com.peachpasswords.sync.AdoptionReceiverV2Deps11import com.peachpasswords.sync.AdoptionSourceDeps12import com.peachpasswords.sync.AdoptionSourceV2Deps13import com.peachpasswords.sync.BackgroundSyncPreference14import com.peachpasswords.sync.DeviceType15import com.peachpasswords.sync.AdoptionFrame16import com.peachpasswords.sync.LocalDeviceIdentity17import com.peachpasswords.sync.PairedDevice18import com.peachpasswords.sync.PairedDeviceStore19import com.peachpasswords.sync.PEER_PROTOCOL_OUTDATED20import com.peachpasswords.sync.PendingVouchedDevice21import com.peachpasswords.sync.PendingVouchedDeviceStore22import com.peachpasswords.sync.SignedDeviceManifest23import com.peachpasswords.sync.VaultSyncAdapter24import com.peachpasswords.sync.PeachSyncService25import com.peachpasswords.sync.RelayAdoptionReceiver26import com.peachpasswords.sync.RelaySettingsStore27import com.peachpasswords.sync.RelayDecision28import com.peachpasswords.sync.VaultStatus29import com.peachpasswords.sync.buildLocalIdentity30import com.peachpasswords.sync.computeVaultId31import com.peachpasswords.sync.getDeviceName32import com.peachpasswords.sync.hexToPubkey33import com.peachpasswords.sync.loadOrCreateLocalIdentity34import com.peachpasswords.sync.localSyncKeypair35import com.peachpasswords.sync.rotateDeviceId36import com.peachpasswords.sync.parsePairingInvitation37import com.peachpasswords.ui.security.scorePassword38import com.peachpasswords.vault.AdoptVaultResult39import com.peachpasswords.vault.VaultCrypto40import com.peachpasswords.vault.VaultRepository41import dagger.hilt.android.lifecycle.HiltViewModel42import dagger.hilt.android.qualifiers.ApplicationContext43import java.util.UUID44import javax.inject.Inject45import kotlinx.coroutines.CompletableDeferred46import kotlinx.coroutines.CancellationException47import kotlinx.coroutines.Job48import kotlinx.coroutines.flow.MutableStateFlow49import kotlinx.coroutines.flow.StateFlow50import kotlinx.coroutines.flow.asStateFlow51import kotlinx.coroutines.launch5253sealed interface AdoptVaultUiState {54    data object Password : AdoptVaultUiState55    data object Pair : AdoptVaultUiState56    data object Connecting : AdoptVaultUiState57    /**58     * Source-initiator flow exposes [isSource] so the screen can phrase the59     * confirmation as "send vault" instead of "adopt/join vault". The SAS is60     * identical to the receiver flow; only the payload direction differs.61     */62    data class ConfirmSas(63        val sas: String,64        val isSource: Boolean = false,65        val peerName: String? = null,66    ) : AdoptVaultUiState67    data object Adopting : AdoptVaultUiState68    data class Complete(val sourceName: String) : AdoptVaultUiState69    data class Failed(val reason: String) : AdoptVaultUiState70}7172@HiltViewModel73class AdoptVaultViewModel @Inject constructor(74    private val receiver: RelayAdoptionReceiver,75    private val vault: VaultRepository,76    private val devices: PairedDeviceStore,77    private val relaySettings: RelaySettingsStore,78    private val backgroundSync: BackgroundSyncPreference,79    private val license: LicenseRepository,80    private val pendingVouchedStore: PendingVouchedDeviceStore,81    @ApplicationContext private val appContext: Context,82) : ViewModel() {83    /**84     * True when this device already has a vault. A populated device may refresh85     * a pairing only for that exact vault identity; it may not adopt a different86     * root key and silently merge two record sets.87     */88    val joining: Boolean = vault.hasVault()8990    private val _state = MutableStateFlow<AdoptVaultUiState>(91        initialAdoptVaultState(joining),92    )93    val state: StateFlow<AdoptVaultUiState> = _state.asStateFlow()9495    private var password: String? = null96    private var confirmation: CompletableDeferred<Boolean>? = null97    private var adoptionJob: Job? = null98    private var protocolError: String? = null99100    fun acceptPassword(value: String, repeated: String): Boolean {101        if (value.length < 12 || value != repeated || scorePassword(value) < 2) {102            return false103        }104        password = value105        _state.value = AdoptVaultUiState.Pair106        return true107    }108109    fun adopt(invitation: String) {110        if (_state.value !is AdoptVaultUiState.Pair) return111        val parsed = parsePairingInvitation(invitation)112        if (parsed == null) {113            _state.value = AdoptVaultUiState.Failed("invalid_pairing_invitation")114            clearPassword()115            return116        }117        when (parsed.vaultStatus) {118            VaultStatus.POPULATED -> startReceiverFlow(invitation)119            VaultStatus.EMPTY -> startSourceFlow(invitation)120        }121    }122123    private fun startReceiverFlow(invitation: String) {124        _state.value = AdoptVaultUiState.Connecting125        protocolError = null126        adoptionJob = viewModelScope.launch {127            try {128                val adopted = receiver.adopt(129                    encodedInvitation = invitation,130                    deps = receiverDeps(),131                    confirm = { sas ->132                        val pending = CompletableDeferred<Boolean>()133                        confirmation = pending134                        _state.value = AdoptVaultUiState.ConfirmSas(135                            sas = sas,136                            isSource = false,137                        )138                        pending.await()139                    },140                )141                // The pair carries the source's relay, but the Sync card reads142                // this device's own setting — left alone it would say "Public143                // relay" on a phone dialling a private one. Peach's own URL144                // still lands on PUBLIC, so joining over the public relay is145                // not relabelled private.146                adopted.relayUrl?.let(relaySettings::setRelayUrl)147                _state.value = AdoptVaultUiState.Complete(148                    adopted.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",149                )150            } catch (error: CancellationException) {151                throw error152            } catch (error: Throwable) {153                _state.value = AdoptVaultUiState.Failed(154                    protocolError ?: error.message ?: "adoption_failed",155                )156            } finally {157                confirmation = null158                clearPassword()159            }160        }161    }162163    /**164     * Source-initiator flow: the scanning invitation came from a vault-less165     * device, so the phone dials the relay, completes Noise, and sends the166     * root key over. Uses the same UI states as the receiver flow so the167     * surface area stays the same; only [AdoptVaultUiState.ConfirmSas.isSource]168     * distinguishes the role for the confirmation copy.169     */170    private fun startSourceFlow(invitation: String) {171        val master = vault.syncMasterKey()172        if (master == null) {173            _state.value = AdoptVaultUiState.Failed("vault_locked")174            clearPassword()175            return176        }177        when (val decision = relaySettings.relayInForce()) {178            is RelayDecision.Refuse -> {179                VaultCrypto.zero(master)180                _state.value = AdoptVaultUiState.Failed(decision.reason.wire())181                clearPassword()182                return183            }184            is RelayDecision.Dial -> Unit185        }186        _state.value = AdoptVaultUiState.Connecting187        protocolError = null188189        val credentials = loadOrCreateLocalIdentity(appContext)190        val keys = localSyncKeypair(credentials)191        val identity = buildLocalIdentity(appContext, credentials)192        VaultCrypto.zero(master)193        adoptionJob = viewModelScope.launch {194            try {195                val paired = receiver.grantFromInvitation(196                    encodedInvitation = invitation,197                    sourceDeps = sourceDeps(identity),198                    // §11.6: a v1 peer after a secure reset is reported as199                    // repair-required rather than as a generic failure.200                    onPeerOutdated = { protocolError = PEER_PROTOCOL_OUTDATED },201                    confirm = { sas, request ->202                        val pending = CompletableDeferred<Boolean>()203                        confirmation = pending204                        _state.value = AdoptVaultUiState.ConfirmSas(205                            sas = sas,206                            isSource = true,207                            peerName = request.deviceName.ifBlank { "New device" },208                        )209                        pending.await()210                    },211                )212                paired.relayUrl?.let(relaySettings::setRelayUrl)213                _state.value = AdoptVaultUiState.Complete(214                    paired.deviceName?.takeIf(String::isNotBlank) ?: "Your other device",215                )216            } catch (error: CancellationException) {217                throw error218            } catch (error: Throwable) {219                _state.value = AdoptVaultUiState.Failed(220                    protocolError ?: error.message ?: "adoption_failed",221                )222            } finally {223                VaultCrypto.zero(keys.secretKey)224                VaultCrypto.zero(keys.publicKey)225                confirmation = null226                clearPassword()227            }228        }229    }230231    fun confirmSas(matches: Boolean) {232        val pending = confirmation ?: return233        confirmation = null234        if (matches) _state.value = AdoptVaultUiState.Adopting235        pending.complete(matches)236    }237238    fun restart() {239        adoptionJob?.cancel()240        adoptionJob = null241        confirmation?.complete(false)242        confirmation = null243        clearPassword()244        _state.value = initialAdoptVaultState(joining)245    }246247    private fun receiverDeps(): AdoptionReceiverV2Deps =248        object : AdoptionReceiverV2Deps {249            override val deviceName = getDeviceName(appContext)250            override val deviceType = DeviceType.ANDROID251            override fun localIdentity(): LocalDeviceIdentity =252                buildLocalIdentity(appContext, loadOrCreateLocalIdentity(appContext))253            // Bound to the relay session by RelayAdoptionReceiver, which wraps254            // this object; the ViewModel itself holds no Noise session.255            override val pairState: AdoptionPairState? get() = null256            override suspend fun rotateLocalIdentity(): LocalDeviceIdentity {257                rotateDeviceId(appContext)258                return localIdentity()259            }260            override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}261            override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare): Boolean =262                devices.isRevoked(prepare.deviceId, prepare.longTermPublicKeyHex)263            override fun generateDeviceId(): String = UUID.randomUUID().toString()264265            // RelayAdoptionReceiver replaces this with the SAS bound to its266            // completed Noise transcript.267            override suspend fun confirmSas(): Boolean = false268269            override suspend fun validateSource(source: AdoptionFrame.Source) {270                // First contact on a populated vault is allowed: a second271                // browser, a tablet, another phone — any device that holds the272                // same root key (the same recovery words) is the same vault.273                // The vault-id check in createVaultFromRootKey is the real274                // security boundary; this callback now only documents intent275                // for callers that want to layer their own policy on top.276                if (!joining) return277            }278279            override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean =280                devices.isRevoked(source.deviceId, source.longTermPublicKeyHex)281282            override suspend fun createVaultFromRootKey(rootKeyHex: String): String {283                // Same protocol seam, two safe outcomes: a fresh device builds284                // around the granted key; a populated device may only refresh a285                // pairing when it already has that exact vault identity.286                val result = if (joining) {287                    val incomingRoot = hexToPubkey(rootKeyHex)288                    val incomingVaultId = try {289                        computeVaultId(incomingRoot)290                    } finally {291                        VaultCrypto.zero(incomingRoot)292                    }293                    AdoptVaultResult.Success(294                        requireSameVaultForPopulatedRePair(295                            localVaultId = vault.syncVaultId(),296                            incomingVaultId = incomingVaultId,297                        ),298                    )299                } else {300                    val localPassword = password301                        ?: throw IllegalStateException("adoption_password_unavailable")302                    vault.createVaultFromRootKey(localPassword, rootKeyHex)303                }304                return when (result) {305                    is AdoptVaultResult.Success -> result.vaultId306                    is AdoptVaultResult.Error -> throw IllegalStateException(result.reason)307                }308            }309310            override suspend fun deriveLocalIdentity(): LocalDeviceIdentity {311                val master = vault.syncMasterKey()312                    ?: throw IllegalStateException("adopted_vault_unavailable")313                return try {314                    buildLocalIdentity(appContext, master)315                } finally {316                    VaultCrypto.zero(master)317                }318            }319320            override suspend fun clearPairedDeviceRevocation(deviceId: String) {321                devices.clearRevocation(deviceId)322            }323324            override suspend fun upsertPairedDeviceAndClearRevocation(device: PairedDevice) {325                devices.upsertAndClearRevocation(device)326            }327328            override suspend fun upsertPairedDevice(device: PairedDevice) {329                devices.upsert(device)330            }331332            override suspend fun applyLicense(licenseKey: String) {333                if (!license.storeLicense(licenseKey) || !license.isProActive()) {334                    throw IllegalStateException("license_transfer_failed")335                }336            }337338            override suspend fun license(): String? =339                license.getLicenseKey()?.takeIf(license::validateLicense)340341            override fun onError(error: String) {342                protocolError = error343            }344345            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>, duressSlot: String?) {346                val credentials = loadOrCreateLocalIdentity(appContext)347                val adapter = VaultSyncAdapter(348                    repo = vault,349                    ourDeviceId = credentials.deviceId,350                    pairedDevices = devices,351                    localCredentials = credentials,352                )353                adapter.applyEntries(entries)354                adapter.applyTombstones(tombstones)355                if (!duressSlot.isNullOrBlank()) {356                    try {357                        val ok = vault.installAdoptedDuressSlot(duressSlot)358                        if (!ok) Log.w("PeachAdopt", "duress slot install returned false; adoption continues")359                    } catch (e: Exception) {360                        Log.w("PeachAdopt", "duress slot install failed; adoption continues", e)361                    }362                }363            }364365            override suspend fun applyIncomingDeviceManifest(366                manifest: SignedDeviceManifest,367                authenticatedRemoteStaticPublicKey: ByteArray,368            ) {369                // Mesh graduation: the source's signed device graph promotes370                // the receiver into the full mesh pool. The receiver must371                // already have the source as a paired device (see applySnapshot372                // above which calls upsertPairedDevice via the Grant path),373                val credentials = loadOrCreateLocalIdentity(appContext)374                val adapter = VaultSyncAdapter(375                    repo = vault,376                    ourDeviceId = credentials.deviceId,377                    pairedDevices = devices,378                    pendingVouched = pendingVouchedStore,379                    localCredentials = credentials,380                )381                adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)382            }383384            override suspend fun markSynced(deviceId: String) {385                devices.updateLastSync(deviceId)386            }387        }388389    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceV2Deps =390        object : AdoptionSourceV2Deps {391            override val sourceIdentity: LocalDeviceIdentity = identity392393            // Bound to the relay session by RelayAdoptionHost / the receiver394            // session, which wrap this object.395            override val pairState: AdoptionPairState? get() = null396397            override fun snapshotCapable(): Boolean = true398399            override suspend fun rootKeyHex(): String {400                val root = vault.syncMasterKey()401                    ?: throw IllegalStateException("vault_locked")402                return try {403                    root.joinToString("") { "%02x".format(it) }404                } finally {405                    VaultCrypto.zero(root)406                }407            }408409            override suspend fun vaultId(): String =410                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")411412            override suspend fun license(): String? =413                license.getLicenseKey()?.takeIf(license::validateLicense)414415            override suspend fun applyLicense(licenseKey: String) {416                if (!license.storeLicense(licenseKey) || !license.isProActive()) {417                    throw IllegalStateException("license_transfer_failed")418                }419            }420421            override suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean = true422423            // The v2 holder is keyed on the validated identity, never on the424            // v1 request label that ADOPT_REQUEST_V2 does not even carry.425            override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean {426                val deviceName = device.deviceName ?: return false427                val deviceType = device.deviceType ?: return false428                val longTermKey = device.longTermPublicKeyHex ?: return false429                val signingKey = device.signingPublicKeyHex ?: return false430                return runCatching {431                    pendingVouchedStore.upsertAndReport(432                        PendingVouchedDevice(433                            deviceId = device.deviceId,434                            deviceName = deviceName,435                            deviceType = deviceType,436                            longTermPublicKeyHex = longTermKey,437                            signingPublicKeyHex = signingKey,438                            wsPort = device.wsPort,439                            pairedAt = nowMs,440                            receivedAt = nowMs,441                            vouchedByDeviceId = identity.deviceId,442                        ),443                    )444                }.getOrDefault(false)445            }446447            override suspend fun completePendingIdentity(device: PairedDevice): Boolean =448                runCatching { pendingVouchedStore.removeIdentityAndReport(device) }.getOrDefault(false)449            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true450            override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean {451                return runCatching {452                    pendingVouchedStore.upsertAndReport(453                        com.peachpasswords.sync.PendingVouchedDevice(454                            deviceId = request.sessionRequestId,455                            deviceName = request.deviceName,456                            deviceType = request.deviceType,457                            longTermPublicKeyHex = identity.longTermPublicKeyHex,458                            signingPublicKeyHex = identity.signingPublicKeyHex,459                            pairedAt = nowMs,460                            receivedAt = nowMs,461                            vouchedByDeviceId = identity.deviceId,462                        ),463                    )464                }.getOrDefault(false)465            }466467            override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean {468                return runCatching {469                    pendingVouchedStore.removeAndReport(request.sessionRequestId)470                }.getOrDefault(false)471            }472473            override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean =474                devices.hasIdentityCollision(device)475476            override suspend fun rotateSourceIdentity(): LocalDeviceIdentity {477                rotateDeviceId(appContext)478                val master = vault.syncMasterKey()479                    ?: throw IllegalStateException("vault_locked")480                return try {481                    buildLocalIdentity(appContext, master)482                } finally {483                    VaultCrypto.zero(master)484                }485            }486487            override suspend fun upsertPairedDevice(device: PairedDevice) {488                // SAS-confirmed direct adoption atomically validates identity,489                // lifts terminal revocation, and persists the paired row.490                devices.upsertAndClearRevocation(device)491            }492493            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {494                val credentials = loadOrCreateLocalIdentity(appContext)495                val adapter = VaultSyncAdapter(496                    repo = vault,497                    ourDeviceId = credentials.deviceId,498                    pairedDevices = devices,499                    localCredentials = credentials,500                )501                val entryManifest = adapter.buildEntryManifest()502                val entryIds = entryManifest.keys.toList()503                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()504                val tombstones = adapter.buildTombstoneManifest()505                return entries to tombstones506            }507            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {508                val credentials = loadOrCreateLocalIdentity(appContext)509                val adapter = VaultSyncAdapter(510                    repo = vault,511                    ourDeviceId = credentials.deviceId,512                    pairedDevices = devices,513                    localCredentials = credentials,514                )515                return adapter.buildOutgoingDeviceManifest()516            }517518            override suspend fun markSynced(deviceId: String) {519                devices.updateLastSync(deviceId)520            }521522            override suspend fun duressSlotJson(): String? =523                vault.exportDuressSlotForAdoption()524        }525526    fun setBackgroundSync(enabled: Boolean) {527        backgroundSync.setEnabled(enabled)528        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)529    }530531    private fun clearPassword() {532        password = null533    }534535    override fun onCleared() {536        adoptionJob?.cancel()537        confirmation?.complete(false)538        clearPassword()539        super.onCleared()540    }541}542543internal fun initialAdoptVaultState(hasVault: Boolean): AdoptVaultUiState =544    if (hasVault) AdoptVaultUiState.Pair else AdoptVaultUiState.Password545546/**547 * Pairing is not a merge tool. A populated destination may re-pair only when548 * both sides already derive the same vault id. Different ids must stop before549 * recovery words, entries, revocations, or paired-device state are changed.550 */551internal fun requireSameVaultForPopulatedRePair(552    localVaultId: String?,553    incomingVaultId: String,554): String {555    if (localVaultId == null) throw IllegalStateException("vault_locked")556    if (localVaultId != incomingVaultId) {557        throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")558    }559    return localVaultId560}