CodePairViewModel.kt

14.9 KB · raw

1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BackgroundSyncPreference9import com.peachpasswords.sync.BatteryExemption10import com.peachpasswords.sync.PairingSession11import com.peachpasswords.sync.PeachSyncService12import com.peachpasswords.sync.RelayAdoptionEvent13import com.peachpasswords.sync.RelayAdoptionHost14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.SyncMode17import com.peachpasswords.sync.PendingVouchedDevice18import com.peachpasswords.sync.PendingVouchedDeviceStore19import com.peachpasswords.sync.AdoptionFrame20import com.peachpasswords.sync.AdoptionPairState21import com.peachpasswords.sync.AdoptionSourceDeps22import com.peachpasswords.sync.AdoptionSourceV2Deps23import com.peachpasswords.sync.LocalDeviceIdentity24import com.peachpasswords.sync.PairedDevice25import com.peachpasswords.sync.PairedDeviceStore26import com.peachpasswords.sync.SignedDeviceManifest27import com.peachpasswords.sync.VaultSyncAdapter28import com.peachpasswords.sync.buildLocalIdentity29import com.peachpasswords.sync.loadOrCreateLocalIdentity30import com.peachpasswords.sync.localSyncKeypair31import com.peachpasswords.sync.rotateDeviceId32import com.peachpasswords.vault.VaultCrypto33import com.peachpasswords.vault.VaultRepository34import com.peachpasswords.license.LicenseRepository35import dagger.hilt.android.lifecycle.HiltViewModel36import dagger.hilt.android.qualifiers.ApplicationContext37import kotlinx.coroutines.Job38import kotlinx.coroutines.flow.MutableStateFlow39import kotlinx.coroutines.flow.StateFlow40import kotlinx.coroutines.flow.asStateFlow41import kotlinx.coroutines.launch42import javax.inject.Inject4344/**45 * The phone half of pairing: occupy a relay room, show the invitation, wait to46 * be scanned.47 *48 * There is one transport. LAN pairing codes are retired along with LAN sync49 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and50 * then presents the SAS for comparison.51 */52sealed interface CodePairUiState {53    data object Starting : CodePairUiState54    data class ShowingInvitation(val invitation: String) : CodePairUiState55    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState56    data class Paired(val deviceName: String) : CodePairUiState57    data object Expired : CodePairUiState58    data class Failed(val reason: String) : CodePairUiState59}6061@HiltViewModel62class CodePairViewModel @Inject constructor(63    private val vault: VaultRepository,64    private val relayHost: RelayAdoptionHost,65    private val relaySettings: RelaySettingsStore,66    private val backgroundSync: BackgroundSyncPreference,67    private val deviceStore: PairedDeviceStore,68    private val pendingVouchedStore: PendingVouchedDeviceStore,69    private val license: LicenseRepository,70    @ApplicationContext private val appContext: Context,71) : ViewModel() {7273    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)74    val state: StateFlow<CodePairUiState> = _state.asStateFlow()7576    private val _syncMode = MutableStateFlow(relaySettings.syncMode())77    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7879    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())80    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()8182    /**83     * The single live transport collector. Switching between relay and LAN84     * restarts the screen, and without cancelling the previous collector both85     * would keep writing [_state] — the older one landing last and winning.86     */87    private var collectJob: Job? = null8889    /**90     * The battery-exemption dialog to show, or null if it's already granted or91     * already been declined once. Consumed by the screen, which owns an92     * Activity to launch it from.93     *94     * [userConfirmed] must come from the tap itself — see [BatteryExemption]'s95     * distribution note. When it yields nothing, sync still works; it just runs96     * from the foreground ([BatteryExemption.syncOnce]).97     */98    fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =99        BatteryExemption.requestIntent(appContext, userConfirmed)100101    /** Degraded sync for a device with no Doze exemption — see [BatteryExemption.syncOnce]. */102    fun syncOnce() = BatteryExemption.syncOnce(appContext)103104    /**105     * The vault owner's answer to the source-side SAS prompt (PB-26-116). The106     * scanning device's own confirmation is not enough: §10.2 puts a decision on107     * the device that hands over the root key too.108     */109    fun confirmSas(matches: Boolean) = relayHost.confirmSas(matches)110111    fun start() {112        val master = vault.syncMasterKey() ?: run {113            _state.value = CodePairUiState.Failed("vault_locked")114            return115        }116        val decision = relaySettings.relayInForce()117        if (decision is RelayDecision.Refuse) {118            _state.value = CodePairUiState.Failed(decision.reason.wire())119            return120        }121        PeachSyncService.start(appContext)122        val credentials = loadOrCreateLocalIdentity(appContext)123        val keys = localSyncKeypair(credentials)124        val identity = buildLocalIdentity(appContext, credentials)125        VaultCrypto.zero(master)126        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))127    }128129    private fun startRelay(130        privateKey: ByteArray,131        publicKey: ByteArray,132        identity: LocalDeviceIdentity,133        sourceDeps: AdoptionSourceV2Deps,134    ) {135        collectJob = viewModelScope.launch {136            try {137                relayHost.start(138                    viewModelScope,139                    privateKey,140                    publicKey,141                    identity,142                    sourceDeps,143                ).collect { event ->144                    _state.value = when (event) {145                        is RelayAdoptionEvent.InvitationReady ->146                            CodePairUiState.ShowingInvitation(event.invitation)147                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->148                            CodePairUiState.AwaitingPeerConfirmation(149                                event.sas,150                                event.deviceName,151                            )152                        is RelayAdoptionEvent.Adopted ->153                            CodePairUiState.Paired(event.deviceName)154                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired155                        is RelayAdoptionEvent.Rejected -> {156                            if (BuildConfig.DEBUG) {157                                Log.w(158                                    "PeachPairing",159                                    "relay adoption rejected: ${event.reason}",160                                )161                            }162                            CodePairUiState.Failed(event.reason)163                        }164                    }165                }166            } finally {167                VaultCrypto.zero(privateKey)168                VaultCrypto.zero(publicKey)169            }170        }171    }172173    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceV2Deps =174        object : AdoptionSourceV2Deps {175            override val sourceIdentity: LocalDeviceIdentity = identity176177            // The relay host binds the live pair state and the human SAS prompt178            // when it wraps this object.179            override val pairState: AdoptionPairState? get() = null180181            override fun snapshotCapable(): Boolean = true182183            override suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean = true184185            // The v2 holder is keyed on the validated identity. ADOPT_REQUEST_V2186            // carries one; there is no request label to key on.187            override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean {188                val deviceName = device.deviceName ?: return false189                val deviceType = device.deviceType ?: return false190                val longTermKey = device.longTermPublicKeyHex ?: return false191                val signingKey = device.signingPublicKeyHex ?: return false192                return runCatching {193                    pendingVouchedStore.upsertAndReport(194                        PendingVouchedDevice(195                            deviceId = device.deviceId,196                            deviceName = deviceName,197                            deviceType = deviceType,198                            longTermPublicKeyHex = longTermKey,199                            signingPublicKeyHex = signingKey,200                            wsPort = device.wsPort,201                            pairedAt = nowMs,202                            receivedAt = nowMs,203                            vouchedByDeviceId = identity.deviceId,204                        ),205                    )206                }.getOrDefault(false)207            }208209            override suspend fun completePendingIdentity(device: PairedDevice): Boolean =210                runCatching { pendingVouchedStore.removeIdentityAndReport(device) }.getOrDefault(false)211212            override suspend fun rootKeyHex(): String {213                val root = vault.syncMasterKey()214                    ?: throw IllegalStateException("vault_locked")215                return try {216                    root.joinToString("") { "%02x".format(it) }217                } finally {218                    VaultCrypto.zero(root)219                }220            }221222            override suspend fun vaultId(): String =223                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")224225            override suspend fun license(): String? =226                license.getLicenseKey()?.takeIf(license::validateLicense)227228            override suspend fun applyLicense(licenseKey: String) {229                if (!license.storeLicense(licenseKey) || !license.isProActive()) {230                    throw IllegalStateException("license_transfer_failed")231                }232            }233234            // Opening this source screen while unlocked is the source-side235            // consent. The scanning device confirms the SAS before any grant.236            override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean =237                runCatching {238                    pendingVouchedStore.upsertAndReport(239                        PendingVouchedDevice(240                            deviceId = request.sessionRequestId,241                            deviceName = request.deviceName,242                            deviceType = request.deviceType,243                            longTermPublicKeyHex = identity.longTermPublicKeyHex,244                            signingPublicKeyHex = identity.signingPublicKeyHex,245                            pairedAt = nowMs,246                            receivedAt = nowMs,247                            vouchedByDeviceId = identity.deviceId,248                        ),249                    )250                }.getOrDefault(false)251            override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean =252                runCatching {253                    pendingVouchedStore.removeAndReport(request.sessionRequestId)254                }.getOrDefault(false)255            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true256257            override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean =258                deviceStore.hasIdentityCollision(device)259260            override suspend fun rotateSourceIdentity(): LocalDeviceIdentity {261                rotateDeviceId(appContext)262                val master = vault.syncMasterKey()263                    ?: throw IllegalStateException("vault_locked")264                return try {265                    buildLocalIdentity(appContext, master)266                } finally {267                    VaultCrypto.zero(master)268                }269            }270271            override suspend fun upsertPairedDevice(device: PairedDevice) {272                // SAS-confirmed direct adoption atomically validates identity,273                // lifts terminal revocation, and persists the paired row.274                deviceStore.upsertAndClearRevocation(device)275            }276277            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {278                val credentials = loadOrCreateLocalIdentity(appContext)279                val adapter = VaultSyncAdapter(280                    repo = vault,281                    ourDeviceId = credentials.deviceId,282                    pairedDevices = deviceStore,283                    localCredentials = credentials,284                )285                val entryManifest = adapter.buildEntryManifest()286                val entryIds = entryManifest.keys.toList()287                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()288                val tombstones = adapter.buildTombstoneManifest()289                return entries to tombstones290            }291292            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {293                val credentials = loadOrCreateLocalIdentity(appContext)294                val adapter = VaultSyncAdapter(295                    repo = vault,296                    ourDeviceId = credentials.deviceId,297                    pairedDevices = deviceStore,298                    localCredentials = credentials,299                )300                return adapter.buildOutgoingDeviceManifest()301            }302303            override suspend fun markSynced(deviceId: String) {304                deviceStore.updateLastSync(deviceId)305            }306307            override suspend fun duressSlotJson(): String? =308                vault.exportDuressSlotForAdoption()309        }310311    fun setBackgroundSync(enabled: Boolean) {312        backgroundSync.setEnabled(enabled)313        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)314    }315316    fun selectRelayMode(mode: SyncMode) {317        require(mode != SyncMode.OFF) { "Pairing requires a relay" }318        relaySettings.setSyncMode(mode)319        refreshRelayState()320        if (relaySettings.relayInForce() is RelayDecision.Dial) {321            restart()322        } else {323            stop()324            _state.value = CodePairUiState.Failed("private_relay_unset")325        }326    }327328    fun savePrivateRelayUrl(input: String): Boolean = runCatching {329        relaySettings.setRelayUrl(input)330        refreshRelayState()331        restart()332        true333    }.getOrDefault(false)334335    private fun refreshRelayState() {336        _syncMode.value = relaySettings.syncMode()337        _privateRelayUrl.value = relaySettings.privateRelayUrl()338    }339340    fun restart() {341        collectJob?.cancel()342        collectJob = null343        relayHost.stop()344        _state.value = CodePairUiState.Starting345        start()346    }347348    fun stop() {349        collectJob?.cancel()350        collectJob = null351        relayHost.stop()352    }353354    override fun onCleared() {355        stop()356        super.onCleared()357    }358}