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 5859606162 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 8485868788 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 142 143 144 145 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 164165166167168169170 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 199 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 254 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 266 267 override suspend fun confirmSas(): Boolean = false268269 override suspend fun validateSource(source: AdoptionFrame.Source) {270 271 272 273 274 275 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 284 285 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 370 371 372 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 394 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 424 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 489 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.Password545546547548549550551internal 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}