1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.MessageDigest9import java.security.SecureRandom10import java.text.Normalizer11import kotlinx.serialization.json.Json12import android.os.SystemClock13import android.util.Log14import androidx.annotation.VisibleForTesting15import com.peachpasswords.BuildConfig16import com.peachpasswords.sync.SyncLimits17import com.peachpasswords.sync.LockedSyncSpoolHooks18import kotlinx.serialization.json.JsonElement19import kotlinx.serialization.json.JsonNull20import kotlinx.serialization.json.JsonObject21import kotlinx.serialization.json.JsonPrimitive22import java.util.Base6423import java.util.UUID24import java.util.concurrent.locks.ReentrantLock25import javax.inject.Inject26import javax.inject.Singleton272829private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }3031private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)32private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)333435private const val PASSWORD_HISTORY_MAX = 5036private const val VAULT_HISTORY_MAX = 537383940414243private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4445sealed interface UnlockResult {46 data object Success : UnlockResult47 data object WrongPassword : UnlockResult48 data class Error(val reason: String) : UnlockResult49}5051sealed interface CreateVaultResult {52 data object Success : CreateVaultResult53 data class Error(val reason: String) : CreateVaultResult54}5556sealed interface AdoptVaultResult {57 data class Success(val vaultId: String) : AdoptVaultResult58 data class Error(val reason: String) : AdoptVaultResult59}6061626364656667686970internal object VaultKeyRotationHooks {71 @Volatile var onRotated: () -> Unit = {}72}7374internal object VaultMutationHooks {75 @Volatile var onCommitted: () -> Unit = {}76}777879808182838485868788899091@Singleton92class VaultRepository @Inject constructor(93 private val store: VaultStorage,94) {95 private val slotRollback = VaultSlotRollback(store)96 private val mutex = Mutex()97 98 99 100 101 102 103 104 private val stateLock = ReentrantLock()105 private val rng = SecureRandom()106107 108109110111112 private var sessionEpoch: Long = 0L113114 private fun nextSessionEpoch(epoch: Long): Long =115 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L116117 118 @VisibleForTesting119 internal var beforeDurableCommit: () -> Unit = {}120121 122 @VisibleForTesting123 internal var afterCodexRollback: () -> Unit = {}124125 126127128129130 private inline fun <T> mutateState(block: () -> T): T {131 stateLock.lock()132 return try { block() } finally { stateLock.unlock() }133 }134135 136 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {137 stateLock.lock()138 return try {139 if (sessionEpoch != epoch) null else block()140 } finally {141 stateLock.unlock()142 }143 }144145 146 @VisibleForTesting147 internal var beforeSessionEpochCommit: () -> Unit = {}148149 150151152153154 @VisibleForTesting155 internal var beforeSessionStateMutation: () -> Unit = {}156157 158159160161162163 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {164 beforeSessionEpochCommit()165 return withEpochCommit(epoch, block)166 }167168 private fun currentEpoch(): Long = mutateState { sessionEpoch }169170 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =171 mutateState { if (sessionEpoch == epoch) block() else null }172173 174 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {175 beforeSessionStateMutation()176 return mutateIfCurrentEpoch(epoch, block)177 }178179 private data class PersistSnapshot(180 val epoch: Long,181 val key: ByteArray,182 val payload: VaultPayload,183 val decoy: Boolean,184 )185186 187 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {188 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null189 val liveKey = derivedKey ?: return@mutateState null190 PersistSnapshot(191 epoch = sessionEpoch,192 key = liveKey.copyOf(),193 payload = currentPayloadLocked(),194 decoy = decoyMode,195 )196 }197198 private var derivedKey: ByteArray? = null199 private var rootKey: ByteArray? = null200 private var entryList: MutableList<VaultEntry> = mutableListOf()201 private var authenticatedDuressConfigured: Boolean = false202 private var recoveryWordHash: String? = null203 private var recoveryWordsSet: Boolean = false204 205 private var sessionDataAvailable: Boolean = false206 207 private var decoyMode: Boolean = false208209 private val _isUnlocked = MutableStateFlow(false)210 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()211212 private val _duressConfigured = MutableStateFlow(false)213 214 val duressConfigured: StateFlow<Boolean> = _duressConfigured.asStateFlow()215216217 218219220221222223 private val _uiGateLocked = MutableStateFlow(false)224 val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()225226 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())227 228 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()229230 231 232 233 234 private val _settings = MutableStateFlow(store.loadSettings())235 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()236237 238 private fun clearSessionStateLocked() {239 derivedKey?.let(VaultCrypto::zero)240 rootKey?.let(VaultCrypto::zero)241 derivedKey = null242 rootKey = null243 entryList = mutableListOf()244 authenticatedDuressConfigured = false245 _duressConfigured.value = false246 recoveryWordHash = null247 recoveryWordsSet = false248 sessionDataAvailable = false249 decoyMode = false250 _settings.value = store.loadSettings()251 _entries.value = emptyList()252 _isUnlocked.value = false253 }254255 fun hasVault(): Boolean = store.hasVault()256257 258259 260261262263264265266 fun codexVaultBlobBase64(): String? {267 val slotA = store.loadSlotA() ?: return null268 269 270 val slotD = store.loadDecoySlot() ?: return null271 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""272 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))273 }274275 276277278279280 281 private fun JsonElement?.asSlotJson(): String? = when {282 this == null || this is JsonNull -> null283 this is JsonPrimitive && isString -> content284 else -> toString()285 }286287 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {288 val expectedEpoch = currentEpoch()289 var candidateKey: ByteArray? = null290 var plaintext: ByteArray? = null291 return try {292 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)293 294 295 296 297 298 299 300 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject301 ?: return UnlockResult.Error("invalid_codex_blob")302 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")303 304 305 306 307 308 val slotA = VaultCodec.decodeSlot(slotJson)309 ?: BrowserSlot.parse(slotJson)?.let {310 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword311 }312 ?: return UnlockResult.Error("invalid_codex_blob")313 314 315 316 317 318 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))319 plaintext = decryptSlotABlob(slotA, candidateKey)320 321 322 323 324 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {325 VaultCrypto.zero(candidateKey)326 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))327 plaintext = decryptSlotABlob(slotA, candidateKey)328 }329 if (plaintext == null) return UnlockResult.WrongPassword330 val payload = try {331 VaultCodec.decode(plaintext)332 } catch (_: Exception) {333 return UnlockResult.Error("invalid_codex_blob")334 }335336 val priorA = store.loadSlotA()337 val priorB = store.loadSlotB()338 val priorD = store.loadDecoySlot()339 340 341 342 343 344 val codexDuressJson = wrapper["b"].asSlotJson()345 val codexDuress: StoredVault? = codexDuressJson?.let {346 VaultCodec.decodeSlot(it)?.let { slot ->347 if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) null else slot348 } ?: BrowserSlot.parse(it)?.let { b ->349 browserDuressPassthrough(b)350 }351 }352353 beforeDurableCommit()354 val committed = runCatching {355 withEpochCommit(expectedEpoch) {356 try {357 358 359 360 361 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")362 363 364 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")365 366 367 val duressOk = if (codexDuress != null) {368 store.writeDecoySlot(codexDuress)369 } else {370 writeFakeDuressSlot(slotA)371 }372 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")373 store.stampLocalMutation()374 true375 } catch (_: Exception) {376 reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD))377 378 379 380 afterCodexRollback()381 false382 }383 } ?: false384 }.getOrDefault(false)385 if (!committed) {386 return UnlockResult.Error("storage_write_failed")387 }388389 val ownedKey = candidateKey!!390 candidateKey = null 391 VaultKeyRotationHooks.onRotated()392 adoptSlotA(ownedKey, payload, expectedEpoch)393 } catch (e: Exception) {394 395 396 397 398 399 400 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))401 UnlockResult.Error(e.message ?: "restore_failed")402 } finally {403 plaintext?.let(VaultCrypto::zero)404 candidateKey?.let(VaultCrypto::zero)405 }406 }407408 409 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =410 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()411412 private fun reportRollbackFailures(failures: List<String>) {413 414 415 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }416 }417418 419 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {420 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)421 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()422 val window = offset?.let {423 val from = (it - 40).coerceAtLeast(0)424 val to = (it + 40).coerceAtMost(wrapperJson.length)425 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")426 }427 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()428 val slot = wrapper?.get("a")429 buildString {430 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)431 append(" | blobBase64=").append(vaultBlobBase64.length)432 append(" wrapperChars=").append(wrapperJson.length)433 append(" wrapperParses=").append(wrapper != null)434 append(" keys=").append(wrapper?.keys)435 append(" slotAKind=").append(436 when {437 slot == null -> "absent"438 slot is JsonPrimitive && slot.isString -> "string"439 slot is JsonObject -> "object:" + slot.keys440 else -> slot::class.simpleName441 },442 )443 if (window != null) append(" masked@").append(offset).append("=").append(window)444 }445 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }446447 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {448 val expectedEpoch = currentEpoch()449 var key: ByteArray? = null450 return try {451 val salt = VaultCrypto.generateSalt()452 key = VaultCrypto.deriveKey(password, salt)453 if (mutateIfCurrentEpoch(expectedEpoch) {454 entryList = mutableListOf()455 authenticatedDuressConfigured = false456 _duressConfigured.value = false457 recoveryWordHash = null458 recoveryWordsSet = false459 decoyMode = false460 sessionDataAvailable = false461 rootKey = generateRecoverableRootKey()462 } == null) return CreateVaultResult.Error("stale_create")463 464 465 466 467 val slotA = sealSlotA(key!!, salt)468 469 470 beforeDurableCommit()471 val created = withEpochCommit(expectedEpoch) {472 if (!store.writeSlotA(slotA)) return@withEpochCommit false473 try {474 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")475 476 477 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")478 store.stampLocalMutation()479 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")480 true481 } catch (_: Exception) {482 483 484 store.clearVault()485 false486 }487 } ?: false488 if (!created) {489 lock()490 return CreateVaultResult.Error("storage_write_failed")491 }492 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")493 if (mutateIfCurrentEpoch(expectedEpoch) {494 derivedKey = sessionKey495 key = null496 publish()497 _isUnlocked.value = true498 sessionDataAvailable = true499 sessionEpoch = nextSessionEpoch(sessionEpoch)500 } == null) return CreateVaultResult.Error("stale_create")501 CreateVaultResult.Success502 } catch (e: Exception) {503 lock()504 CreateVaultResult.Error(e.message ?: "create_failed")505 } finally {506 key?.let(VaultCrypto::zero)507 }508 }509510 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {511 val expectedEpoch = currentEpoch()512 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")513 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")514 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {515 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")516 }517 var adoptedRootKey: ByteArray? = null518 var nextDerivedKey: ByteArray? = null519 var sessionRootKey: ByteArray? = null520 return try {521 adoptedRootKey = hexToRootKey(rootKeyHex)522 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {523 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")524 }525 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)526 val salt = VaultCrypto.generateSalt()527 nextDerivedKey = VaultCrypto.deriveKey(password, salt)528 sessionRootKey = adoptedRootKey.copyOf()529 val payload = VaultPayload(530 entries = emptyList(),531 duressConfigured = false,532 rootKeyB64 = b64(sessionRootKey),533 recoveryWordHash = null,534 recoveryWordsSet = false,535 )536 val plaintext = VaultCodec.encode(payload)537 val blob = try {538 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))539 } finally {540 VaultCrypto.zero(plaintext)541 }542 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)543 544 545 beforeDurableCommit()546 val created = withEpochCommit(expectedEpoch) {547 if (!store.writeSlotA(slotA)) return@withEpochCommit false548 try {549 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")550 551 552 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")553 store.stampLocalMutation()554 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")555 true556 } catch (_: Exception) {557 store.clearVault()558 false559 }560 } ?: false561 if (!created) return AdoptVaultResult.Error("storage_write_failed")562563 val adopted = mutateIfCurrentEpoch(expectedEpoch) {564 val previousDerivedKey = derivedKey565 val previousRootKey = rootKey566 derivedKey = nextDerivedKey567 rootKey = sessionRootKey568 nextDerivedKey = null569 sessionRootKey = null570 entryList = mutableListOf()571 authenticatedDuressConfigured = false572 _duressConfigured.value = false573 recoveryWordHash = null574 recoveryWordsSet = false575 decoyMode = false576 sessionDataAvailable = true577 publish()578 _isUnlocked.value = true579 sessionEpoch = nextSessionEpoch(sessionEpoch)580 previousDerivedKey to previousRootKey581 }582 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")583 val (previousDerivedKey, previousRootKey) = adopted584 previousDerivedKey?.let(VaultCrypto::zero)585 previousRootKey?.let(VaultCrypto::zero)586 587 588 589 runCatching {590 val words = rootKeyToWords(rootKey!!)591 val escrow = buildRecoveryEscrow(words)592 val rkEscrow = buildRootKeyEscrow(words)593 if (escrow != null || rkEscrow != null) {594 val prior = store.loadSlotA()595 if (prior != null) {596 store.writeSlotA(prior.copy(597 recoveryEscrow = escrow ?: prior.recoveryEscrow,598 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,599 ))600 }601 mutateState {602 recoveryWordHash = recoveryWordsHash(words)603 recoveryWordsSet = true604 }605 }606 }607 AdoptVaultResult.Success(vaultId)608 } catch (e: Exception) {609 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")610 } finally {611 adoptedRootKey?.let(VaultCrypto::zero)612 nextDerivedKey?.let(VaultCrypto::zero)613 sessionRootKey?.let(VaultCrypto::zero)614 }615 }616617 suspend fun unlock(password: String): UnlockResult = mutex.withLock {618 val result = unlockLocked(password, currentEpoch())619 if (result is UnlockResult.Success) ensureRecoveryEscrow()620 result621 }622623 624 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {625 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")626 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))627 val payload = try {628 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)629 try {630 VaultCodec.decode(plaintext)631 } finally {632 VaultCrypto.zero(plaintext)633 }634 } catch (_: Exception) {635 VaultCrypto.zero(key)636 637 638 639 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {640 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)641 if (legacy != null) return legacy642 }643 return UnlockResult.WrongPassword644 }645 return adoptSlotA(key, payload, expectedEpoch)646 }647648 649650651652653654655656 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {657 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))658 var newKey: ByteArray? = null659 return try {660 val payload = try {661 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)662 try {663 VaultCodec.decode(plaintext)664 } finally {665 VaultCrypto.zero(plaintext)666 }667 } catch (_: Exception) {668 return null669 }670 671 672 val newSalt = VaultCrypto.generateSalt()673 newKey = VaultCrypto.deriveKey(password, newSalt)674 val plaintext = VaultCodec.encode(payload)675 val blob = try {676 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))677 } finally {678 VaultCrypto.zero(plaintext)679 }680 val next = StoredVault(681 version = slotA.version,682 salt = b64(newSalt),683 blob = blob,684 recoveryEscrow = slotA.recoveryEscrow,685 rootKeyEscrow = slotA.rootKeyEscrow,686 )687 688 689 690 val priorB = store.loadSlotB()691 beforeDurableCommit()692 val healed = withEpochCommit(expectedEpoch) {693 if (!store.writeSlotA(next)) {694 false695 } else try {696 if (!writeMatchingNoiseSlotB(next)) {697 runCatching { store.writeSlotA(slotA) }698 if (priorB != null) runCatching { store.writeSlotB(priorB) }699 false700 } else if (!writeCompanionDuressSlot(next)) {701 runCatching { store.writeSlotA(slotA) }702 if (priorB != null) runCatching { store.writeSlotB(priorB) }703 false704 } else {705 store.stampLocalMutation()706 true707 }708 } catch (_: Exception) {709 runCatching { store.writeSlotA(slotA) }710 if (priorB != null) runCatching { store.writeSlotB(priorB) }711 false712 }713 } ?: false714 if (healed) {715 VaultCrypto.zero(rawKey!!)716 rawKey = null717 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 718 newKey = null719 result720 } else {721 VaultCrypto.zero(newKey!!)722 newKey = null723 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 724 rawKey = null725 result726 }727 } finally {728 729 730 rawKey?.let(VaultCrypto::zero)731 newKey?.let(VaultCrypto::zero)732 }733 }734735 736 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {737 return try {738 739 740 741 742 743 val adopted = mutateIfCurrentEpoch(expectedEpoch) {744 derivedKey = key745 decoyMode = false746 entryList = payload.entries.toMutableList()747 authenticatedDuressConfigured = payload.duressConfigured748 _duressConfigured.value = payload.duressConfigured749 recoveryWordHash = payload.recoveryWordHash750 recoveryWordsSet = payload.recoveryWordsSet751 sessionDataAvailable = true752 _settings.value = store.loadSettings()753 ensureRootKey(payload)754 autoPurgeExpiredTrash()755 consolidatePasskeyEntries()756 publish()757 _isUnlocked.value = true758 759 760 761 sessionEpoch = nextSessionEpoch(sessionEpoch)762 }763 if (adopted == null) {764 VaultCrypto.zero(key)765 UnlockResult.Error("stale_unlock")766 } else {767 UnlockResult.Success768 }769 } catch (e: Exception) {770 771 772 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }773 VaultCrypto.zero(key)774 UnlockResult.Error(e.message ?: "unlock_failed")775 }776 }777778 779780 781782783784785786 class SlotAUnlock internal constructor(787 key: ByteArray,788 internal val payload: VaultPayload,789 internal val epoch: Long,790 ) {791 private var ownedKey: ByteArray? = key792 private var consumed = false793794 795 internal fun takeKey(): ByteArray? = synchronized(this) {796 if (consumed) return@synchronized null797 consumed = true798 ownedKey.also { ownedKey = null }799 }800801 802 internal fun discard() = synchronized(this) {803 if (consumed) return@synchronized804 consumed = true805 ownedKey?.let(VaultCrypto::zero)806 ownedKey = null807 }808 }809810 811812813814815816 fun tryUnlockSlotA(password: String): SlotAUnlock? {817 val epoch = currentEpoch()818 val slotA = store.loadSlotA() ?: return null819 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))820 try {821 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)822 val payload = try {823 VaultCodec.decode(plaintext)824 } finally {825 VaultCrypto.zero(plaintext)826 }827 return SlotAUnlock(key, payload, epoch)828 } catch (_: Exception) {829 VaultCrypto.zero(key)830 }831832 if (password == Normalizer.normalize(password, Normalizer.Form.NFC)) return null833 return tryLegacyRawCandidate(password, slotA, epoch)834 }835836 837 private fun tryLegacyRawCandidate(password: String, slotA: StoredVault, epoch: Long): SlotAUnlock? {838 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))839 var newKey: ByteArray? = null840 return try {841 val plaintext = try {842 VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)843 } catch (_: Exception) {844 return null845 }846 val payload = try {847 VaultCodec.decode(plaintext)848 } finally {849 VaultCrypto.zero(plaintext)850 }851 val newSalt = VaultCrypto.generateSalt()852 newKey = VaultCrypto.deriveKey(password, newSalt)853 val encoded = VaultCodec.encode(payload)854 val blob = try {855 b64(VaultCrypto.encrypt(encoded, newKey!!, VaultCrypto.AAD_SLOT_A))856 } finally {857 VaultCrypto.zero(encoded)858 }859 val next = slotA.copy(salt = b64(newSalt), blob = blob)860 val priorB = store.loadSlotB()861 beforeDurableCommit()862 val healed = withEpochCommit(epoch) {863 if (!store.writeSlotA(next)) false else try {864 if (!writeMatchingNoiseSlotB(next) || !writeCompanionDuressSlot(next)) {865 runCatching { store.writeSlotA(slotA) }866 if (priorB != null) runCatching { store.writeSlotB(priorB) }867 false868 } else {869 store.stampLocalMutation()870 true871 }872 } catch (_: Exception) {873 runCatching { store.writeSlotA(slotA) }874 if (priorB != null) runCatching { store.writeSlotB(priorB) }875 false876 }877 } ?: false878 if (healed) {879 VaultCrypto.zero(rawKey!!)880 rawKey = null881 SlotAUnlock(newKey!!, payload, epoch).also { newKey = null }882 } else {883 VaultCrypto.zero(newKey!!)884 newKey = null885 SlotAUnlock(rawKey!!, payload, epoch).also { rawKey = null }886 }887 } finally {888 rawKey?.let(VaultCrypto::zero)889 newKey?.let(VaultCrypto::zero)890 }891 }892893 894 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {895 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")896 adoptSlotA(key, unlock.payload, unlock.epoch)897 }898899 900 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()901902 903904 905906907908909 class DuressUnlock internal constructor(910 key: ByteArray,911 internal val payload: VaultPayload,912 internal val epoch: Long,913 ) {914 private var ownedKey: ByteArray? = key915 private var consumed = false916917 internal fun takeKey(): ByteArray? = synchronized(this) {918 if (consumed) return@synchronized null919 consumed = true920 ownedKey.also { ownedKey = null }921 }922923 internal fun discard() = synchronized(this) {924 if (consumed) return@synchronized925 consumed = true926 ownedKey?.let(VaultCrypto::zero)927 ownedKey = null928 }929 }930931 932933934935936937938 fun tryUnlockDuress(password: String): DuressUnlock? {939 val epoch = currentEpoch()940 val slot = store.loadDecoySlot() ?: return null941 if (slot.version != LEGACY_ANDROID_SLOT_VERSION && slot.version != CURRENT_ANDROID_SLOT_VERSION) return null942 var key: ByteArray? = VaultCrypto.deriveKey(password, unb64(slot.salt))943 return try {944 val plaintext = try {945 if (slot.version == CURRENT_ANDROID_SLOT_VERSION) {946 VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B)947 } else {948 runCatching {949 VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_A)950 }.getOrElse {951 VaultCrypto.decrypt(unb64(slot.blob), key!!, VaultCrypto.AAD_SLOT_B)952 }953 }954 } catch (_: Exception) {955 VaultCrypto.zero(key!!)956 key = null957 return null958 }959 val payload = try {960 VaultCodec.decode(plaintext)961 } finally {962 VaultCrypto.zero(plaintext)963 }964 if (slot.version == LEGACY_ANDROID_SLOT_VERSION) {965 val migratedKey = migrateLegacyDuressSlot(slot, password, payload, key!!, epoch)966 ?: run {967 VaultCrypto.zero(key!!)968 key = null969 return null970 }971 if (migratedKey !== key) {972 VaultCrypto.zero(key!!)973 key = migratedKey974 }975 }976 DuressUnlock(key!!, payload, epoch).also { key = null }977 } catch (_: Exception) {978 key?.let(VaultCrypto::zero)979 null980 }981 }982983 984985986987988 private fun migrateLegacyDuressSlot(989 legacy: StoredVault,990 password: String,991 payload: VaultPayload,992 legacyKey: ByteArray,993 epoch: Long,994 ): ByteArray? {995 var salt: ByteArray? = null996 var migratedKey: ByteArray? = null997 var plaintext: ByteArray? = null998 var restoredLegacy = false999 var migrated = false1000 try {1001 salt = VaultCrypto.generateSalt()1002 migratedKey = VaultCrypto.deriveKey(password, salt)1003 plaintext = VaultCodec.encode(payload)1004 val next = StoredVault(1005 version = CURRENT_ANDROID_SLOT_VERSION,1006 salt = b64(salt),1007 blob = b64(VaultCrypto.encrypt(plaintext, migratedKey, VaultCrypto.AAD_SLOT_B)),1008 )1009 var priorA: StoredVault? = null1010 var priorB: String? = null1011 withSessionEpochCommit(epoch) {1012 priorA = store.loadSlotA()1013 priorB = store.loadSlotB()1014 if (store.loadDecoySlot() != legacy) return@withSessionEpochCommit false1015 if (!store.writeDecoySlot(next)) {1016 restoredLegacy = store.loadDecoySlot() == legacy1017 return@withSessionEpochCommit restoredLegacy1018 }1019 val reopened = store.loadDecoySlot()1020 val verified = reopened?.version == CURRENT_ANDROID_SLOT_VERSION && runCatching {1021 val reopenedPlaintext = VaultCrypto.decrypt(unb64(reopened.blob), migratedKey!!, VaultCrypto.AAD_SLOT_B)1022 try { VaultCodec.decode(reopenedPlaintext) == payload } finally { VaultCrypto.zero(reopenedPlaintext) }1023 }.getOrDefault(false)1024 if (verified) {1025 migrated = true1026 return@withSessionEpochCommit true1027 }1028 val restored = priorA != null && store.restoreVaultTriad(priorA!!, priorB, legacy)1029 restoredLegacy = restored && store.loadSlotA() == priorA && store.loadSlotB() == priorB && store.loadDecoySlot() == legacy1030 restoredLegacy1031 }1032 if (migrated) {1033 VaultCrypto.zero(legacyKey)1034 return migratedKey.also { migratedKey = null }1035 }1036 if (restoredLegacy) return legacyKey1037 } catch (_: Exception) {1038 1039 1040 }1041 if (!restoredLegacy) runCatching { store.loadDecoySlot() == legacy }1042 migratedKey?.let(VaultCrypto::zero)1043 return if (restoredLegacy) legacyKey else null1044 }10451046 1047 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {1048 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")1049 if (currentEpoch() != unlock.epoch) {1050 VaultCrypto.zero(key)1051 return@withLock UnlockResult.Error("stale_unlock")1052 }1053 return try {1054 val adopted = mutateIfCurrentEpoch(unlock.epoch) {1055 derivedKey = key1056 decoyMode = true1057 entryList = unlock.payload.entries.toMutableList()1058 authenticatedDuressConfigured = false1059 _duressConfigured.value = false1060 recoveryWordHash = unlock.payload.recoveryWordHash1061 recoveryWordsSet = unlock.payload.recoveryWordsSet1062 sessionDataAvailable = true1063 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)1064 else VaultCrypto.generateRootKey()1065 1066 1067 1068 1069 1070 _settings.value = VaultSettings()1071 publish()1072 _isUnlocked.value = true1073 sessionEpoch = nextSessionEpoch(sessionEpoch)1074 }1075 if (adopted == null) {1076 VaultCrypto.zero(key)1077 UnlockResult.Error("stale_unlock")1078 } else {1079 UnlockResult.Success1080 }1081 } catch (e: Exception) {1082 1083 1084 1085 1086 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }1087 VaultCrypto.zero(key)1088 UnlockResult.Error(e.message ?: "decoy_unlock_failed")1089 }1090 }10911092 1093 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()10941095 109610971098109911001101 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {1102 1103 val expectedEpoch = mutateState {1104 if (derivedKey != null && !decoyMode) sessionEpoch else null1105 } ?: return false1106 1107 1108 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }1109 var key: ByteArray? = null1110 var basePlaintext: ByteArray? = null1111 return try {1112 val salt = VaultCrypto.generateSalt()1113 key = VaultCrypto.deriveKey(duressPassphrase, salt)1114 1115 val decoyRoot = VaultCrypto.generateRootKey()1116 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }1117 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)1118 basePlaintext = VaultCodec.encode(decoyPayload)1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 val slotA = store.loadSlotA()1129 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 01130 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!1131 val blob = try {1132 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_B))1133 } finally {1134 VaultCrypto.zero(plaintext)1135 1136 1137 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)1138 basePlaintext = null1139 key?.let(VaultCrypto::zero)1140 key = null1141 }1142 beforeDurableCommit()1143 val ok = withEpochCommit(expectedEpoch) {1144 store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob))1145 } ?: false1146 if (!ok) return false1147 val marked = mutateIfCurrentEpoch(expectedEpoch) {1148 authenticatedDuressConfigured = true1149 _duressConfigured.value = true1150 true1151 } ?: false1152 if (!marked) return false1153 if (!runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) {1154 mutateIfCurrentEpoch(expectedEpoch) {1155 authenticatedDuressConfigured = false1156 _duressConfigured.value = false1157 }1158 runCatching { writeFakeDuressSlot(store.loadSlotA()!!) }1159 return false1160 }1161 true1162 } catch (_: Exception) {1163 false1164 } finally {1165 1166 1167 key?.let(VaultCrypto::zero)1168 basePlaintext?.let(VaultCrypto::zero)1169 }1170 }117111721173 117411751176117711781179118011811182 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1183 if (!authenticatedDuressConfigured) return null1184 val slot = store.loadDecoySlot() ?: return null1185 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1186 }11871188 1189119011911192119311941195 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1196 1197 1198 1199 1200 1201 var salt: ByteArray? = null1202 var blob: ByteArray? = null1203 var nonce: ByteArray? = null1204 var ctAndTag: ByteArray? = null1205 var ct: ByteArray? = null1206 var tag: ByteArray? = null1207 try {1208 salt = unb64(slot.salt)1209 blob = unb64(slot.blob)1210 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1211 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1212 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1213 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1214 val obj = JsonObject(mapOf(1215 "version" to JsonPrimitive(1),1216 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1217 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1218 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1219 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1220 ))1221 return obj.toString()1222 } finally {1223 salt?.let(VaultCrypto::zero)1224 blob?.let(VaultCrypto::zero)1225 nonce?.let(VaultCrypto::zero)1226 ctAndTag?.let(VaultCrypto::zero)1227 ct?.let(VaultCrypto::zero)1228 tag?.let(VaultCrypto::zero)1229 }1230 }12311232 123312341235 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1236 val b = BrowserSlot.parse(json) ?: return false1237 if (b.version != 1) return false1238 val expectedEpoch = currentEpoch()1239 var salt: ByteArray? = null1240 var nonce: ByteArray? = null1241 var ct: ByteArray? = null1242 var tag: ByteArray? = null1243 var blob: ByteArray? = null1244 try {1245 salt = unhex(b.salt)1246 nonce = unhex(b.nonce)1247 ct = unhex(b.ciphertext)1248 tag = unhex(b.tag)1249 blob = ByteArray(nonce.size + ct.size + tag.size).also {1250 System.arraycopy(nonce, 0, it, 0, nonce.size)1251 System.arraycopy(ct, 0, it, nonce.size, ct.size)1252 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1253 }1254 val stored = StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob))1255 val priorA = store.loadSlotA() ?: return false1256 val priorB = store.loadSlotB()1257 val priorD = store.loadDecoySlot() ?: return false1258 beforeDurableCommit()1259 val committed = withEpochCommit(expectedEpoch) {1260 if (!store.writeDecoySlot(stored)) return@withEpochCommit false1261 authenticatedDuressConfigured = true1262 _duressConfigured.value = true1263 true1264 } ?: false1265 if (!committed) return false1266 if (runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }.getOrDefault(false)) {1267 true1268 } else {1269 mutateIfCurrentEpoch(expectedEpoch) {1270 authenticatedDuressConfigured = false1271 _duressConfigured.value = false1272 }1273 withEpochCommit(expectedEpoch) { store.restoreVaultTriad(priorA, priorB, priorD) }1274 false1275 }1276 } catch (_: Exception) {1277 false1278 } finally {1279 salt?.let(VaultCrypto::zero)1280 nonce?.let(VaultCrypto::zero)1281 ct?.let(VaultCrypto::zero)1282 tag?.let(VaultCrypto::zero)1283 blob?.let(VaultCrypto::zero)1284 }1285 }12861287 1288 suspend fun clearDuress(): Boolean = mutex.withLock {1289 1290 1291 1292 1293 val expectedEpoch = currentEpoch()1294 val replaced = withEpochCommit(expectedEpoch) {1295 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1296 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1297 } ?: false1298 if (!replaced) return@withLock false1299 1300 1301 if (!isSessionEpochCurrent(expectedEpoch)) return false1302 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1303 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) {1304 authenticatedDuressConfigured = false1305 _duressConfigured.value = false1306 } != null) {1307 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1308 }1309 true1310 }13111312 1313 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1314 var current: Throwable? = error1315 while (current != null) {1316 if (current is javax.crypto.AEADBadTagException) return true1317 1318 1319 1320 1321 if (current is javax.crypto.BadPaddingException) return true1322 current = current.cause1323 }1324 return false1325 }13261327 1328 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1329 val expectedEpoch = currentEpoch()1330 val slotA = try {1331 store.loadSlotA()1332 } catch (_: Exception) {1333 return@withLock UnlockResult.Error("unlock_key_storage_failed")1334 } ?: return@withLock UnlockResult.Error("no_vault")1335 1336 1337 1338 1339 var stagedKey: ByteArray? = key.copyOf()1340 return try {1341 val encrypted = try {1342 unb64(slotA.blob)1343 } catch (_: Exception) {1344 return@withLock UnlockResult.Error("invalid_vault_blob")1345 }1346 1347 1348 1349 1350 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1351 return@withLock UnlockResult.Error("invalid_vault_blob")1352 }1353 val plaintext = try {1354 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1355 } catch (e: Exception) {1356 if (isGcmAuthenticationFailure(e)) {1357 return@withLock UnlockResult.WrongPassword1358 }1359 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1360 }1361 val payload = try {1362 VaultCodec.decode(plaintext)1363 } catch (_: Exception) {1364 return@withLock UnlockResult.Error("invalid_vault_payload")1365 } finally {1366 VaultCrypto.zero(plaintext)1367 }1368 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1369 val result = adoptSlotA(candidate, payload, expectedEpoch)1370 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1371 result1372 } finally {1373 stagedKey?.let(VaultCrypto::zero)1374 }1375 }13761377 fun lock() {1378 1379 1380 1381 1382 if (mutex.tryLock()) {1383 try {1384 runCatching { LockedSyncSpoolHooks.refresh(this) }1385 } finally {1386 mutex.unlock()1387 }1388 }1389 1390 1391 1392 1393 1394 1395 stateLock.lock()1396 try {1397 sessionEpoch = nextSessionEpoch(sessionEpoch)1398 clearSessionStateLocked()1399 _uiGateLocked.value = false1400 } finally {1401 stateLock.unlock()1402 }1403 }14041405 1406140714081409141014111412 fun lockUiGate() {1413 if (_isUnlocked.value) _uiGateLocked.value = true1414 }14151416 1417 fun clearUiGate() {1418 _uiGateLocked.value = false1419 }14201421 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L14221423 1424 1425 @VisibleForTesting1426 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }14271428 142914301431143214331434143514361437143814391440 fun expectTransientBackground() {1441 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1442 }14431444 14451446144714481449 fun consumeTransientBackgroundSuppression(): Boolean {1450 val until = suppressNextBackgroundLockUntil1451 val active = until != 0L && nowMs() < until1452 suppressNextBackgroundLockUntil = 0L1453 return active1454 }14551456 14571458 14591460146114621463 14641465146614671468146914701471147214731474 1475 1476 1477 1478 1479 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)14801481 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1482 if (decoyMode || !_isUnlocked.value) null1483 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1484 }14851486 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1487 mutateState { sessionEpoch == epoch }14881489 internal fun unlockedSessionEpoch(): Long? = mutateState {1490 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1491 }14921493 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key14941495 149614971498149915001501150215031504 fun sessionRootKey(): ByteArray? = mutateState {1505 if (!sessionDataAvailable) null else rootKey?.copyOf()1506 }15071508 15091510 fun syncVaultId(): String? = mutateState {1511 if (decoyMode || !_isUnlocked.value) null1512 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1513 }15141515 15161517151815191520 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1521 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1522 ?: return false1523 installRootKey(generateRecoverableRootKey(), expectedEpoch)1524 }15251526 15271528152915301531153215331534153515361537153815391540154115421543 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1544 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1545 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1546 }1547 val expectedEpoch = mutateState {1548 when {1549 derivedKey == null -> null1550 decoyMode -> Long.MIN_VALUE1551 else -> sessionEpoch1552 }1553 }1554 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1555 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1556 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")15571558 val incoming = hexToRootKey(rootKeyHex)1559 if (incoming.size != VaultCrypto.KEY_SIZE) {1560 VaultCrypto.zero(incoming)1561 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1562 }1563 1564 1565 1566 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1567 if (!roundTrips) {1568 VaultCrypto.zero(incoming)1569 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1570 }15711572 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1573 return if (installRootKey(incoming, expectedEpoch)) {1574 AdoptVaultResult.Success(vaultId)1575 } else {1576 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1577 }1578 }15791580 15811582158315841585 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1586 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1587 val bytes = hexToRootKey(rootKeyHex)1588 return try {1589 val words = rootKeyToWords(bytes)1590 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1591 } catch (_: Exception) {1592 null1593 } finally {1594 VaultCrypto.zero(bytes)1595 }1596 }15971598 15991600160116021603 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1604 1605 1606 1607 1608 1609 1610 data class Snapshot(1611 val key: ByteArray,1612 val priorRootKey: ByteArray,1613 val priorRecoveryHash: String?,1614 val priorRecoverySet: Boolean,1615 )1616 val snap = mutateIfCurrentEpoch(expectedEpoch) {1617 val k = derivedKey ?: return@mutateIfCurrentEpoch null1618 if (decoyMode) return@mutateIfCurrentEpoch null1619 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1620 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1621 } ?: run {1622 VaultCrypto.zero(nextRootKey)1623 return false1624 }1625 val priorSlot = store.loadSlotA() ?: run {1626 VaultCrypto.zero(snap.key)1627 VaultCrypto.zero(snap.priorRootKey)1628 VaultCrypto.zero(nextRootKey)1629 return false1630 }1631 val nextWords = rootKeyToWords(nextRootKey)16321633 return try {1634 beforeDurableCommit()1635 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1636 if (mutateIfCurrentEpoch(expectedEpoch) {1637 rootKey = nextRootKey1638 recoveryWordHash = recoveryWordsHash(nextWords)1639 recoveryWordsSet = false1640 } == null) throw IllegalStateException("stale_root_rotation")16411642 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1643 ?: throw IllegalStateException("recovery_escrow_failed")1644 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1645 ?: throw IllegalStateException("root_escrow_failed")1646 val plaintext = VaultCodec.encode(currentPayload())1647 val blob = try {1648 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1649 } finally {1650 VaultCrypto.zero(plaintext)1651 }1652 val nextSlot = priorSlot.copy(1653 blob = blob,1654 recoveryEscrow = nextRecoveryEscrow,1655 rootKeyEscrow = nextRootKeyEscrow,1656 )1657 val committed = withEpochCommit(expectedEpoch) {1658 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1659 try {1660 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1661 1662 1663 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1664 store.stampLocalMutation()1665 } catch (_: Exception) {1666 store.writeSlotA(priorSlot)1667 return@withEpochCommit false1668 }1669 true1670 } ?: false1671 if (!committed) throw IllegalStateException("storage_write_failed")1672 mutateIfCurrentEpoch(expectedEpoch) { publish() }1673 true1674 } catch (_: Exception) {1675 val restored = mutateIfCurrentEpoch(expectedEpoch) {1676 rootKey?.let(VaultCrypto::zero)1677 rootKey = snap.priorRootKey.copyOf()1678 recoveryWordHash = snap.priorRecoveryHash1679 recoveryWordsSet = snap.priorRecoverySet1680 }1681 if (restored != null) {1682 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1683 }1684 false1685 } finally {1686 VaultCrypto.zero(snap.key)1687 VaultCrypto.zero(snap.priorRootKey)1688 1689 1690 1691 val stillOwnedBySession = mutateState {1692 sessionEpoch == expectedEpoch && rootKey === nextRootKey1693 }1694 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1695 }1696 }16971698 16991700170117021703 fun currentVaultKey(): ByteArray? = mutateState {1704 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1705 }17061707 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1708 val expectedEpoch = currentEpoch()1709 val slotA = store.loadSlotA() ?: return false1710 var oldKey: ByteArray? = null1711 var newKey: ByteArray? = null1712 var plaintext: ByteArray? = null1713 return try {1714 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1715 1716 1717 1718 1719 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1720 val payload = try {1721 VaultCodec.decode(decrypted)1722 } finally {1723 VaultCrypto.zero(decrypted)1724 }1725 val newSalt = VaultCrypto.generateSalt()1726 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1727 1728 plaintext = VaultCodec.encode(payload)1729 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1730 1731 1732 1733 1734 1735 1736 1737 val next = StoredVault(1738 version = slotA.version,1739 salt = b64(newSalt),1740 blob = blob,1741 recoveryEscrow = slotA.recoveryEscrow,1742 rootKeyEscrow = slotA.rootKeyEscrow,1743 )1744 1745 1746 1747 1748 val priorB = store.loadSlotB()1749 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1750 1751 1752 1753 if (mutateIfCurrentEpoch(expectedEpoch) {1754 oldKey?.let(VaultCrypto::zero); oldKey = null1755 derivedKey?.let(VaultCrypto::zero)1756 derivedKey = newKey; newKey = null1757 } == null) return false1758 VaultKeyRotationHooks.onRotated()1759 true1760 } catch (_: Exception) {1761 false1762 } finally {1763 oldKey?.let(VaultCrypto::zero)1764 newKey?.let(VaultCrypto::zero)1765 plaintext?.let(VaultCrypto::zero)1766 }1767 }17681769 1770177117721773 suspend fun destroyVault(): Boolean = mutex.withLock {1774 lock()1775 val cleared = store.clearVault()1776 store.clearDecoySlot()1777 cleared1778 }17791780 17811782 1783178417851786178717881789179017911792179317941795 private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")17961797 private fun String?.normalizedEntryId(): String =1798 if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()17991800 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1801 val epoch = requireUnlocked()1802 val stamp = VaultCrypto.monotonicEntryTimestamp()1803 val now = System.currentTimeMillis()1804 val entry = withDerivedLast4(draft.copy(1805 id = draft.id.normalizedEntryId(),1806 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1807 modifiedAt = stamp,1808 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1809 ))1810 1811 1812 val rollback = mutateCurrentSession(epoch) {1813 val r = entryList.toMutableList()1814 entryList.add(entry)1815 r1816 } ?: throw IllegalStateException("vault_locked")1817 persistOrRollback(rollback, epoch)1818 entry1819 }18201821 182218231824182518261827182818291830 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1831 val epoch = requireUnlocked()1832 if (drafts.isEmpty()) return 01833 val now = System.currentTimeMillis()1834 val ready = drafts.map { draft ->1835 val stamp = VaultCrypto.monotonicEntryTimestamp()1836 withDerivedLast4(1837 draft.copy(1838 id = draft.id.normalizedEntryId(),1839 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1840 modifiedAt = stamp,1841 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1842 ),1843 )1844 }1845 1846 val rollback = mutateCurrentSession(epoch) {1847 val r = entryList.toMutableList()1848 entryList.addAll(ready)1849 r1850 } ?: throw IllegalStateException("vault_locked")1851 persistOrRollback(rollback, epoch)1852 return drafts.size1853 }18541855 1856185718581859186018611862186318641865186618671868 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1869 val epoch = requireUnlocked()1870 val now = System.currentTimeMillis()1871 val ready = newEntries.map { draft ->1872 val stamp = VaultCrypto.monotonicEntryTimestamp()1873 withDerivedLast4(1874 draft.copy(1875 id = draft.id.normalizedEntryId(),1876 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1877 modifiedAt = stamp,1878 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1879 ),1880 )1881 }1882 1883 1884 1885 1886 1887 1888 val incomingIds = ready.map { it.id }.toHashSet()1889 val rollback = mutateCurrentSession(epoch) {1890 val r = entryList.toMutableList()1891 val replaced = entryList.map { entry ->1892 if (entry.deletedAt != null) return@map entry1893 if (entry.id in incomingIds) return@map null1894 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1895 entry.copy(deletedAt = stamp, modifiedAt = stamp)1896 }.filterNotNull()1897 entryList = (replaced + ready).toMutableList()1898 r1899 } ?: throw IllegalStateException("vault_locked")1900 persistOrRollback(rollback, epoch)1901 return ready.size1902 }19031904 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1905 val epoch = requireUnlocked()1906 1907 1908 1909 1910 val prepared = mutateCurrentSession(epoch) {1911 val idx = entryList.indexOfFirst { it.id == patch.id }1912 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1913 val current = entryList[idx]1914 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1915 1916 val passwordChanged = patch.password != null && patch.password != current.password1917 1918 1919 1920 1921 1922 1923 val priorPassword = current.password1924 val shouldCapture = passwordChanged &&1925 !priorPassword.isNullOrEmpty() &&1926 current.passwordHistory?.firstOrNull()?.password != priorPassword1927 val nextHistory = if (shouldCapture) {1928 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1929 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1930 } else {1931 current.passwordHistory1932 }1933 val updated = withDerivedLast4(patch.copy(1934 createdAt = current.createdAt,1935 modifiedAt = stamp,1936 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1937 passwordHistory = nextHistory,1938 ))1939 val rollback = entryList.toMutableList()1940 entryList[idx] = updated1941 updated to rollback1942 } ?: throw IllegalStateException("vault_locked")1943 val (updated, rollback) = prepared1944 persistOrRollback(rollback, epoch)1945 updated1946 }19471948 1949 suspend fun deleteEntry(id: String) = mutex.withLock {1950 val epoch = requireUnlocked()1951 1952 1953 val rollback = mutateCurrentSession(epoch) {1954 val idx = entryList.indexOfFirst { it.id == id }1955 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1956 val current = entryList[idx]1957 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1958 val prior = entryList.toMutableList()1959 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1960 prior1961 } ?: throw IllegalStateException("vault_locked")1962 persistOrRollback(rollback, epoch)1963 }19641965 suspend fun restoreEntry(id: String) = mutex.withLock {1966 val epoch = requireUnlocked()1967 val rollback = mutateCurrentSession(epoch) {1968 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1969 if (idx == -1) throw IllegalStateException("deleted entry not found")1970 val current = entryList[idx]1971 val prior = entryList.toMutableList()1972 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1973 prior1974 } ?: throw IllegalStateException("vault_locked")1975 persistOrRollback(rollback, epoch)1976 }19771978 1979 suspend fun purgeEntry(id: String) = mutex.withLock {1980 val epoch = requireUnlocked()1981 val rollback = mutateCurrentSession(epoch) {1982 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1983 if (idx == -1) throw IllegalStateException("deleted entry not found")1984 val prior = entryList.toMutableList()1985 entryList.removeAt(idx)1986 prior1987 } ?: throw IllegalStateException("vault_locked")1988 persistOrRollback(rollback, epoch)1989 }19901991 1992 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1993 val epoch = requireUnlocked()1994 1995 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1996 val r = mutateCurrentSession(epoch) {1997 val rollback = entryList.toMutableList()1998 val before = entryList.size1999 entryList.removeAll { it.deletedAt != null }2000 R(rollback, before - entryList.size)2001 } ?: throw IllegalStateException("vault_locked")2002 if (r.removed > 0) persistOrRollback(r.rollback, epoch)2003 r.removed2004 }20052006 2007 suspend fun countDuplicateEntries(): Int = mutex.withLock {2008 val epoch = requireUnlocked()2009 2010 2011 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }2012 ?: throw IllegalStateException("vault_locked")2013 }20142015 20162017201820192020 suspend fun deduplicateEntries(): Int = mutex.withLock {2021 val epoch = requireUnlocked()2022 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)2023 val ready = mutateCurrentSession(epoch) {2024 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")2025 val active = entryList.filter { it.deletedAt == null }2026 val keepIds = mutableSetOf<String>()2027 active.groupBy { it.duplicateKey() }.values.forEach { group ->2028 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id2029 }2030 val rollback = entryList.toMutableList()2031 var removed = 02032 entryList = entryList.map { entry ->2033 if (entry.deletedAt == null && entry.id !in keepIds) {2034 removed++2035 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)2036 entry.copy(deletedAt = stamp, modifiedAt = stamp)2037 } else {2038 entry2039 }2040 }.toMutableList()2041 R(rollback, removed)2042 } ?: throw IllegalStateException("vault_locked")2043 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)2044 ready.removed2045 }20462047 fun getEntries(): List<VaultEntry> = mutateState {2048 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }2049 }2050 fun getEntry(id: String): VaultEntry? = mutateState {2051 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }2052 }2053 fun getDeletedEntries(): List<VaultEntry> = mutateState {2054 if (!sessionDataAvailable) emptyList()2055 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }2056 }20572058 2059 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {2060 return mutateState {2061 if (!sessionDataAvailable) return@mutateState emptyList()2062 val active = entryList.filter { it.deletedAt == null }2063 if (tagFilter == null) return@mutateState active2064 val needle = tagFilter.trim().lowercase()2065 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }2066 }2067 }20682069 2070 fun getDistinctTags(): List<String> = mutateState {2071 if (!sessionDataAvailable) return@mutateState emptyList()2072 entryList.asSequence()2073 .filter { it.deletedAt == null }2074 .flatMap { it.tags?.asSequence() ?: emptySequence() }2075 .map { it.trim().lowercase() }2076 .filter { it.isNotEmpty() }2077 .distinct()2078 .sorted()2079 .toList()2080 }20812082 208320842085208620872088 fun getDistinctFieldValues(field: String): List<String> {2089 val select: (VaultEntry) -> String? = when (field) {2090 "username" -> { e -> e.username }2091 "email" -> { e -> e.email }2092 else -> return emptyList()2093 }2094 return mutateState {2095 if (!sessionDataAvailable) return@mutateState emptyList()2096 entryList.asSequence()2097 .filter { it.deletedAt == null }2098 .mapNotNull { select(it)?.trim() }2099 .filter { it.isNotEmpty() }2100 .groupingBy { it.lowercase() }2101 .aggregate { _, acc: Pair<String, Int>?, value, _ ->2102 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)2103 }2104 .values2105 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })2106 .map { it.first }2107 }2108 }21092110 fun getTombstones(): List<Tombstone> = mutateState {2111 if (!sessionDataAvailable) emptyList()2112 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }2113 }2114 fun getVaultVersion(): Long = store.version()2115 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots21162117 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {2118 val expectedEpoch = currentEpoch()2119 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false2120 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false2121 2122 2123 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)2124 val priorA = store.loadSlotA()2125 val priorB = store.loadSlotB()2126 val priorD = store.loadDecoySlot()2127 2128 2129 2130 2131 2132 val priorHistory = store.loadVaultHistory()2133 2134 2135 2136 2137 beforeDurableCommit()2138 if (!takeVaultSnapshot("restore", expectedEpoch)) {2139 return false2140 }2141 2142 2143 2144 val nextD: StoredVault = restoredD ?: run {2145 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 162146 val fakeBlob = ByteArray(target).also(rng::nextBytes)2147 val fakeSalt = VaultCrypto.generateSalt()2148 StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob))2149 }2150 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {2151 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }2152 return false2153 }2154 lock()2155 true2156 }21572158 21592160216121622163216421652166216721682169 private fun commitRestoreAtomic(2170 nextA: StoredVault,2171 nextB: String,2172 nextD: StoredVault,2173 priorA: StoredVault?,2174 priorB: String?,2175 priorD: StoredVault?,2176 expectedEpoch: Long,2177 ): Boolean {2178 return withEpochCommit(expectedEpoch) {2179 if (!store.writeSlotA(nextA)) return@withEpochCommit false2180 2181 2182 2183 try {2184 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")2185 2186 2187 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")2188 store.stampLocalMutation()2189 } catch (_: Exception) {2190 reportRollbackFailures(slotRollback.restoreTriad(priorA, priorB, priorD))2191 return@withEpochCommit false2192 }2193 true2194 } ?: false2195 }21962197 21982199 22002201220222032204 suspend fun applyMergedRemoteEntries(2205 remoteEntries: List<VaultEntry>,2206 remoteTombstones: List<Tombstone>,2207 expectedEpoch: Long? = null,2208 ): Int = mutex.withLock {2209 val epoch = expectedEpoch ?: requireUnlocked()2210 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02211 2212 2213 2214 2215 2216 val mergeNow = System.currentTimeMillis()2217 val clamped = remoteEntries.mapNotNull { entry ->2218 2219 2220 2221 SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2222 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2223 deletedAt = entry.deletedAt?.let {2224 SyncLimits.clampSyncTimestamp(it, mergeNow)2225 },2226 )2227 }2228 val tombMap = remoteTombstones.associate {2229 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2230 }2231 2232 2233 val applied = mutateCurrentSession(epoch) {2234 var n = 02235 val byId = entryList.associateBy { it.id }.toMutableMap()22362237 for (remote in clamped) {2238 val tomb = tombMap[remote.id]2239 if (tomb != null && tomb >= remote.modifiedAt) continue2240 val local = byId[remote.id]2241 if (local == null) {2242 entryList.add(remote); byId[remote.id] = remote; n++2243 } else if (remote.modifiedAt > local.modifiedAt) {2244 val i = entryList.indexOfFirst { it.id == local.id }2245 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2246 }2247 }22482249 for ((id, deletedAt) in tombMap) {2250 val local = byId[id] ?: continue2251 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2252 if (deletedAt < local.modifiedAt) continue2253 val i = entryList.indexOfFirst { it.id == id }2254 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2255 }2256 n2257 } ?: return@withLock 022582259 if (applied > 0) {2260 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2261 mutateIfCurrentEpoch(epoch) { publish() }2262 runCatching { LockedSyncSpoolHooks.refresh(this) }2263 }2264 applied2265 }22662267 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2268 2269 2270 2271 2272 if (mutateState { decoyMode }) {2273 val decoyEpoch = requireUnlocked()2274 mutateCurrentSession(decoyEpoch) { _settings.value = settings }2275 ?: throw IllegalStateException("vault_locked")2276 return@withLock2277 }2278 2279 2280 2281 val epoch = requireUnlocked()2282 val prior = mutateCurrentSession(epoch) {2283 val old = _settings.value2284 _settings.value = settings2285 old2286 } ?: throw IllegalStateException("vault_locked")22872288 val committed = try {2289 beforeDurableCommit()2290 2291 2292 2293 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2294 } catch (_: Exception) {2295 false2296 }2297 if (!committed) {2298 2299 2300 2301 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2302 throw IllegalStateException("settings_write_failed")2303 }2304 }23052306 23072308 2309 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }23102311 23122313231423152316 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2317 val epoch = requireUnlocked()2318 2319 2320 2321 2322 2323 2324 val prior = mutateCurrentSession(epoch) {2325 val priorPair = recoveryWordHash to recoveryWordsSet2326 recoveryWordHash = recoveryWordsHash(words)2327 recoveryWordsSet = true2328 priorPair2329 } ?: return@withLock false2330 2331 2332 2333 2334 val persisted = runCatching {2335 persistSlotAWithEscrows(2336 buildRecoveryEscrow(words),2337 buildRootKeyEscrow(words),2338 expectedEpoch = epoch,2339 )2340 }.getOrDefault(false)2341 if (!persisted) {2342 mutateIfCurrentEpoch(epoch) {2343 recoveryWordHash = prior.first2344 recoveryWordsSet = prior.second2345 }2346 }2347 persisted2348 }23492350 2351 fun verifyRecoveryWords(words: List<String>): Boolean {2352 return mutateState {2353 val stored = recoveryWordHash ?: return@mutateState false2354 MessageDigest.isEqual(2355 stored.toByteArray(Charsets.UTF_8),2356 recoveryWordsHash(words).toByteArray(Charsets.UTF_8),2357 )2358 }2359 }23602361 23622363 2364 fun hasRecoveryEscrow(): Boolean =2365 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false23662367 23682369237023712372 private fun ensureRecoveryEscrow() {2373 if (hasRecoveryEscrow()) return2374 val rk = mutateState { rootKey?.copyOf() } ?: return2375 runCatching {2376 val words = rootKeyToWords(rk)2377 val escrow = buildRecoveryEscrow(words)2378 val rkEscrow = buildRootKeyEscrow(words)2379 if (escrow != null || rkEscrow != null) {2380 val prior = store.loadSlotA() ?: return@runCatching2381 store.writeSlotA(prior.copy(2382 recoveryEscrow = escrow ?: prior.recoveryEscrow,2383 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2384 ))2385 mutateState {2386 recoveryWordHash = recoveryWordsHash(words)2387 recoveryWordsSet = true2388 }2389 }2390 }.also { VaultCrypto.zero(rk) }2391 }23922393 239423952396239723982399240024012402240324042405 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2406 val expectedEpoch = currentEpoch()2407 val slotA = store.loadSlotA() ?: return false2408 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false24092410 2411 2412 val fullEscrow = slotA.recoveryEscrow2413 if (fullEscrow != null) {2414 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2415 if (material != null) {2416 var keyBytes: ByteArray? = null2417 var plaintext: ByteArray? = null2418 var candidateRoot: ByteArray? = null2419 var stateCommitStarted = false2420 try {2421 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2422 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2423 val payload = VaultCodec.decode(plaintext)2424 2425 2426 2427 2428 val candidateSettings = store.loadSettings()2429 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2430 val decodedRoot = unb64(payload.rootKeyB64)2431 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2432 2433 2434 2435 VaultCrypto.zero(decodedRoot)2436 throw IllegalArgumentException("invalid recovery root key")2437 }2438 decodedRoot2439 } else {2440 generateRecoverableRootKey()2441 }2442 stateCommitStarted = true2443 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2444 clearSessionStateLocked()2445 entryList = payload.entries.toMutableList()2446 authenticatedDuressConfigured = payload.duressConfigured2447 _duressConfigured.value = payload.duressConfigured2448 recoveryWordHash = payload.recoveryWordHash2449 recoveryWordsSet = payload.recoveryWordsSet2450 rootKey = candidateRoot2451 candidateRoot = null2452 _settings.value = candidateSettings2453 publish()2454 2455 2456 sessionDataAvailable = true2457 _isUnlocked.value = false2458 true2459 }2460 if (adopted == true) return@withLock true2461 } catch (_: Exception) {2462 2463 2464 2465 2466 if (stateCommitStarted) {2467 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2468 }2469 } finally {2470 plaintext?.let(VaultCrypto::zero)2471 VaultCrypto.zero(material)2472 keyBytes?.let(VaultCrypto::zero)2473 candidateRoot?.let(VaultCrypto::zero)2474 }2475 }2476 }24772478 2479 2480 2481 val rootEscrow = slotA.rootKeyEscrow2482 if (rootEscrow != null) {2483 var candidateRoot: ByteArray? = null2484 var stateCommitStarted = false2485 try {2486 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2487 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2488 val candidateSettings = store.loadSettings()2489 stateCommitStarted = true2490 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2491 clearSessionStateLocked()2492 entryList = mutableListOf()2493 authenticatedDuressConfigured = false2494 _duressConfigured.value = false2495 recoveryWordHash = recoveryWordsHash(words)2496 recoveryWordsSet = true2497 rootKey = candidateRoot2498 candidateRoot = null2499 _settings.value = candidateSettings2500 publish()2501 sessionDataAvailable = true2502 _isUnlocked.value = false2503 true2504 }2505 if (adopted == true) return@withLock true2506 } catch (_: Exception) {2507 if (stateCommitStarted) {2508 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2509 }2510 } finally {2511 candidateRoot?.let(VaultCrypto::zero)2512 }2513 }2514 return false2515 }25162517 251825192520252125222523252425252526 suspend fun resetPasswordWithRecoveryWords(2527 words: List<String>,2528 newPassword: String,2529 ): Boolean = mutex.withLock {2530 val expectedEpoch = currentEpoch()2531 2532 2533 2534 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2535 val priorA = store.loadSlotA() ?: return false2536 var newKey: ByteArray? = null2537 var plaintext: ByteArray? = null2538 return try {2539 val newSalt = VaultCrypto.generateSalt()2540 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2541 plaintext = VaultCodec.encode(currentPayload())2542 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2543 2544 2545 2546 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2547 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2548 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2549 val next = StoredVault(2550 version = priorA.version,2551 salt = b64(newSalt),2552 blob = blob,2553 recoveryEscrow = freshRecovery,2554 rootKeyEscrow = freshRootKey,2555 )2556 2557 2558 2559 val priorB = store.loadSlotB()2560 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2561 2562 2563 2564 if (mutateIfCurrentEpoch(expectedEpoch) {2565 derivedKey?.let(VaultCrypto::zero)2566 derivedKey = newKey; newKey = null2567 publish()2568 _isUnlocked.value = true2569 } == null) return false2570 VaultKeyRotationHooks.onRotated()2571 true2572 } catch (_: Exception) {2573 false2574 } finally {2575 newKey?.let(VaultCrypto::zero)2576 plaintext?.let(VaultCrypto::zero)2577 }2578 }25792580 258125822583258425852586 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2587 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2588 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2589 var keyBytes: ByteArray? = null2590 var plaintext: ByteArray? = null2591 return try {2592 val salt = VaultCrypto.generateSalt()2593 val recoveryKey = deriveRecoveryKey(material, salt)2594 keyBytes = recoveryKey.encoded2595 plaintext = VaultCodec.encode(currentPayload())2596 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2597 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2598 } finally {2599 VaultCrypto.zero(material)2600 keyBytes?.let(VaultCrypto::zero)2601 plaintext?.let(VaultCrypto::zero)2602 }2603 }26042605 26062607260826092610261126122613 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2614 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2615 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2616 val rk = mutateState { rootKey?.copyOf() } ?: run {2617 VaultCrypto.zero(material)2618 return null2619 }2620 var keyBytes: ByteArray? = null2621 var plaintext: ByteArray? = null2622 return try {2623 val salt = VaultCrypto.generateSalt()2624 val recoveryKey = deriveRecoveryKey(material, salt)2625 keyBytes = recoveryKey.encoded2626 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2627 2628 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2629 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2630 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2631 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2632 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2633 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2634 } finally {2635 VaultCrypto.zero(rk)2636 VaultCrypto.zero(material)2637 keyBytes?.let(VaultCrypto::zero)2638 plaintext?.let(VaultCrypto::zero)2639 }2640 }26412642 2643 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2644 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2645 var keyBytes: ByteArray? = null2646 var pt: ByteArray? = null2647 return try {2648 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2649 keyBytes = recoveryKey.encoded2650 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2651 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2652 hexToRootKey(String(pt, Charsets.US_ASCII))2653 } catch (_: Exception) {2654 null2655 } finally {2656 2657 2658 2659 VaultCrypto.zero(material)2660 keyBytes?.let(VaultCrypto::zero)2661 pt?.let(VaultCrypto::zero)2662 }2663 }26642665 266626672668266926702671 private fun persistSlotAWithEscrows(2672 recoveryEscrow: RecoveryEscrow?,2673 rootKeyEscrow: RootKeyEscrow?,2674 expectedEpoch: Long? = null,2675 ): Boolean {2676 val snapshot = snapshotForPersist(expectedEpoch)2677 ?: throw IllegalStateException("vault_locked_or_stale")2678 if (snapshot.decoy) {2679 VaultCrypto.zero(snapshot.key)2680 return false2681 }2682 try {2683 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2684 val plaintext = VaultCodec.encode(snapshot.payload)2685 val blob = try {2686 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2687 } finally {2688 2689 2690 VaultCrypto.zero(plaintext)2691 }2692 val next = prior.copy(2693 blob = blob,2694 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2695 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2696 )2697 beforeDurableCommit()2698 return withEpochCommit(snapshot.epoch) {2699 if (!store.writeSlotA(next)) return@withEpochCommit false2700 try {2701 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2702 2703 2704 2705 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2706 } catch (e: Exception) {2707 store.writeSlotA(prior)2708 throw e2709 }2710 true2711 } ?: false2712 } finally {2713 VaultCrypto.zero(snapshot.key)2714 }2715 }27162717 27182719 private fun requireUnlocked(): Long {2720 2721 2722 2723 stateLock.lock()2724 try {2725 if (derivedKey == null) throw IllegalStateException("vault_locked")2726 return sessionEpoch2727 } finally {2728 stateLock.unlock()2729 }2730 }27312732 2733 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null27342735 private fun countDuplicateEntriesLocked(): Int {2736 val seen = mutableMapOf<String, VaultEntry>()2737 var duplicates = 02738 for (entry in entryList.filter { it.deletedAt == null }) {2739 val key = entry.duplicateKey()2740 val existing = seen[key]2741 if (existing == null) {2742 seen[key] = entry2743 } else {2744 duplicates++2745 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2746 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2747 if (entryStamp > existingStamp) seen[key] = entry2748 }2749 }2750 return duplicates2751 }27522753 private fun VaultEntry.duplicateKey(): String {2754 if (type == "note") return "note|${title.lowercase()}"2755 val passkeyData = passkey2756 if (passkeyData != null) {2757 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2758 }2759 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2760 }27612762 276327642765276627672768 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2769 val epoch = expectedEpoch ?: currentEpoch()2770 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2771 val slotA = store.loadSlotA() ?: return false2772 val vaultA = VaultCodec.encodeSlot(slotA)2773 val vaultB = store.loadSlotB() ?: slotA.blob2774 2775 2776 2777 2778 2779 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2780 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162781 val fakeBlob = ByteArray(target).also(rng::nextBytes)2782 val fakeSalt = VaultCrypto.generateSalt()2783 VaultCodec.encodeSlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob)))2784 }2785 val history = store.loadVaultHistory()2786 val latest = history.snapshots.firstOrNull()2787 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2788 val entryCount = mutateIfCurrentEpoch(epoch) {2789 if (derivedKey != null && !decoyMode) {2790 bucketEntryCount(entryList.count { it.deletedAt == null })2791 } else {2792 null2793 }2794 } ?: return false2795 return try {2796 val snapshot = VaultSnapshot(2797 id = UUID.randomUUID().toString(),2798 savedAt = System.currentTimeMillis(),2799 reason = reason,2800 vault_a = vaultA,2801 vault_b = vaultB,2802 vault_d = vaultD,2803 entryCount = entryCount,2804 )2805 2806 2807 2808 withEpochCommit(epoch) {2809 store.writeVaultHistory(2810 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2811 )2812 } ?: false2813 } catch (_: Exception) {2814 false2815 }2816 }28172818 private fun bucketEntryCount(n: Int): Int = when {2819 n <= 0 -> 02820 n <= 10 -> 102821 n <= 25 -> 252822 n <= 50 -> 502823 n <= 100 -> 1002824 n <= 250 -> 2502825 n <= 500 -> 5002826 else -> ((n + 499) / 500) * 5002827 }28282829 283028312832283328342835283628372838 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2839 if (!entry.isCard()) return entry2840 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2841 val derived = if (digits.length >= 4) digits.takeLast(4) else null2842 return entry.copy(last4 = derived ?: entry.last4)2843 }28442845 284628472848284928502851 private fun autoPurgeExpiredTrash() {2852 val days = _settings.value.trashRetentionDays ?: return2853 if (days <= 0) return2854 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2855 val before = entryList.size2856 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2857 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2858 }28592860 286128622863286428652866 private fun consolidatePasskeyEntries() {2867 val passkeyOnly = entryList.filter { entry ->2868 entry.deletedAt == null &&2869 entry.isLogin() &&2870 entry.password.isNullOrBlank() &&2871 !entry.rpId.isNullOrBlank() &&2872 !entry.credentialId.isNullOrBlank()2873 }2874 if (passkeyOnly.isEmpty()) return2875 var changed = false2876 val now = System.currentTimeMillis()2877 for (pkEntry in passkeyOnly) {2878 val rpId = pkEntry.rpId ?: continue2879 val target = entryList.firstOrNull { candidate ->2880 candidate.deletedAt == null &&2881 candidate.id != pkEntry.id &&2882 candidate.isLogin() &&2883 candidate.passkey == null &&2884 candidate.credentialId.isNullOrBlank() &&2885 !candidate.password.isNullOrBlank() &&2886 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2887 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2888 } ?: continue2889 val idx = entryList.indexOfFirst { it.id == target.id }2890 if (idx < 0) continue2891 entryList[idx] = target.copy(2892 rpId = pkEntry.rpId,2893 rpName = pkEntry.rpName ?: target.rpName,2894 credentialId = pkEntry.credentialId,2895 publicKeyCose = pkEntry.publicKeyCose,2896 privateKeyJwk = pkEntry.privateKeyJwk,2897 userHandle = pkEntry.userHandle,2898 userName = pkEntry.userName,2899 userDisplayName = pkEntry.userDisplayName,2900 algorithm = pkEntry.algorithm,2901 signCount = pkEntry.signCount,2902 lastUsedAt = pkEntry.lastUsedAt,2903 modifiedAt = now,2904 )2905 2906 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2907 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2908 changed = true2909 }2910 if (changed) runCatching { persistSlotA(bumpVersion = false) }2911 }29122913 2914 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }29152916 private fun currentPayloadLocked(): VaultPayload =2917 VaultPayload(2918 entries = entryList.toList(),2919 duressConfigured = authenticatedDuressConfigured,2920 rootKeyB64 = rootKey?.let(::b64) ?: "",2921 recoveryWordHash = recoveryWordHash,2922 recoveryWordsSet = recoveryWordsSet,2923 )29242925 29262927292829292930 private fun ensureRootKey(payload: VaultPayload) {2931 if (payload.rootKeyB64.isNotBlank()) {2932 rootKey = unb64(payload.rootKeyB64)2933 return2934 }2935 rootKey = generateRecoverableRootKey()2936 runCatching { persistSlotA(bumpVersion = false) }2937 }29382939 private fun publish() {2940 _entries.value = entryList.filter { it.deletedAt == null }2941 }29422943 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2944 var plaintext = VaultCodec.encode(currentPayload())2945 return try {2946 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2947 StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = blob)2948 } finally {2949 2950 2951 2952 VaultCrypto.zero(plaintext)2953 }2954 }29552956 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2957 2958 2959 2960 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2961 val noise = ByteArray(approxBytes).also(rng::nextBytes)2962 return store.writeSlotB(b64(noise))2963 }29642965 296629672968 private fun matchingCiphertextLength(slotA: StoredVault): Int =2969 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)29702971 29722973297429752976 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2977 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162978 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2979 val fakeSalt = VaultCrypto.generateSalt()2980 return store.writeDecoySlot(StoredVault(version = CURRENT_ANDROID_SLOT_VERSION, salt = b64(fakeSalt), blob = b64(fakeBlob)))2981 }29822983 298429852986298729882989 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2990 val isConfigured = mutateState { authenticatedDuressConfigured }2991 return if (isConfigured) true else writeFakeDuressSlot(slotA)2992 }29932994 2995299629972998299930003001 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {3002 val salt = unhex(b.salt)3003 val nonce = unhex(b.nonce)3004 val ct = unhex(b.ciphertext)3005 val tag = unhex(b.tag)3006 val blob = ByteArray(nonce.size + ct.size + tag.size).also {3007 System.arraycopy(nonce, 0, it, 0, nonce.size)3008 System.arraycopy(ct, 0, it, nonce.size, ct.size)3009 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)3010 }3011 return StoredVault(version = LEGACY_ANDROID_SLOT_VERSION, salt = b64(salt), blob = b64(blob))3012 }3013 3014 private fun persistSlotA(3015 bumpVersion: Boolean,3016 snapshotReason: String? = "local_save",3017 expectedEpoch: Long? = null,3018 ): Boolean {3019 val snapshot = snapshotForPersist(expectedEpoch)3020 ?: throw IllegalStateException("vault_locked_or_stale")3021 return try {3022 3023 if (snapshot.decoy) {3024 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")3025 val plaintext = VaultCodec.encode(snapshot.payload)3026 val blob = try {3027 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_B))3028 } finally {3029 3030 3031 VaultCrypto.zero(plaintext)3032 }3033 beforeDurableCommit()3034 return withEpochCommit(snapshot.epoch) {3035 store.writeDecoySlot(prior.copy(version = CURRENT_ANDROID_SLOT_VERSION, blob = blob))3036 } ?: false3037 }3038 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")3039 3040 3041 3042 3043 val priorB = store.loadSlotB()3044 beforeDurableCommit()3045 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false3046 val plaintext = VaultCodec.encode(snapshot.payload)3047 val blob = try {3048 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))3049 } finally {3050 3051 3052 VaultCrypto.zero(plaintext)3053 }3054 val next = prior.copy(blob = blob)3055 3056 3057 3058 3059 withEpochCommit(snapshot.epoch) {3060 if (!store.writeSlotA(next)) return@withEpochCommit false3061 try {3062 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")3063 3064 3065 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")3066 if (bumpVersion) store.stampLocalMutation()3067 } catch (e: Exception) {3068 store.writeSlotA(prior)3069 if (priorB != null) runCatching { store.writeSlotB(priorB) }3070 throw e3071 }3072 true3073 } ?: false3074 } finally {3075 3076 3077 VaultCrypto.zero(snapshot.key)3078 }3079 }30803081 3082308330843085308630873088 private fun commitVaultRotation(3089 priorA: StoredVault,3090 priorB: String?,3091 nextA: StoredVault,3092 expectedEpoch: Long,3093 ): Boolean {3094 beforeDurableCommit()3095 return withEpochCommit(expectedEpoch) {3096 if (!store.writeSlotA(nextA)) return@withEpochCommit false3097 3098 3099 3100 try {3101 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")3102 3103 3104 3105 3106 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")3107 store.stampLocalMutation()3108 } catch (_: Exception) {3109 reportRotationRollback(slotRollback.restorePair(priorA, priorB))3110 return@withEpochCommit false3111 }3112 true3113 } ?: false3114 }31153116 31173118311931203121 private fun reportRotationRollback(failures: List<String>) {3122 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }3123 }31243125 3126 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {3127 try {3128 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {3129 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }3130 throw IllegalStateException("storage_write_failed")3131 }3132 } catch (e: Exception) {3133 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }3134 throw e3135 }3136 3137 3138 3139 mutateIfCurrentEpoch(expectedEpoch) { publish() }3140 runCatching { LockedSyncSpoolHooks.refresh(this) }3141 runCatching { VaultMutationHooks.onCommitted() }3142 }3143}