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.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import android.os.SystemClock12import android.util.Log13import androidx.annotation.VisibleForTesting14import com.peachpasswords.BuildConfig15import com.peachpasswords.sync.SyncLimits16import com.peachpasswords.sync.LockedSyncSpoolHooks17import kotlinx.serialization.json.JsonElement18import kotlinx.serialization.json.JsonNull19import kotlinx.serialization.json.JsonObject20import kotlinx.serialization.json.JsonPrimitive21import java.util.Base6422import java.util.UUID23import java.util.concurrent.locks.ReentrantLock24import javax.inject.Inject25import javax.inject.Singleton262728private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2930private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)31private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)323334private const val PASSWORD_HISTORY_MAX = 5035private const val VAULT_HISTORY_MAX = 536373839404142private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L4344sealed interface UnlockResult {45 data object Success : UnlockResult46 data object WrongPassword : UnlockResult47 data class Error(val reason: String) : UnlockResult48}4950sealed interface CreateVaultResult {51 data object Success : CreateVaultResult52 data class Error(val reason: String) : CreateVaultResult53}5455sealed interface AdoptVaultResult {56 data class Success(val vaultId: String) : AdoptVaultResult57 data class Error(val reason: String) : AdoptVaultResult58}596061626364656667686970717273@Singleton74class VaultRepository @Inject constructor(75 private val store: VaultStorage,76) {77 private val mutex = Mutex()78 79 80 81 82 83 84 85 private val stateLock = ReentrantLock()86 private val rng = SecureRandom()8788 8990919293 private var sessionEpoch: Long = 0L9495 private fun nextSessionEpoch(epoch: Long): Long =96 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L9798 99 @VisibleForTesting100 internal var beforeDurableCommit: () -> Unit = {}101102 103 @VisibleForTesting104 internal var afterCodexRollback: () -> Unit = {}105106 107108109110111 private inline fun <T> mutateState(block: () -> T): T {112 stateLock.lock()113 return try { block() } finally { stateLock.unlock() }114 }115116 117 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {118 stateLock.lock()119 return try {120 if (sessionEpoch != epoch) null else block()121 } finally {122 stateLock.unlock()123 }124 }125126 127 @VisibleForTesting128 internal var beforeSessionEpochCommit: () -> Unit = {}129130 131132133134135 @VisibleForTesting136 internal var beforeSessionStateMutation: () -> Unit = {}137138 139140141142143144 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {145 beforeSessionEpochCommit()146 return withEpochCommit(epoch, block)147 }148149 private fun currentEpoch(): Long = mutateState { sessionEpoch }150151 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =152 mutateState { if (sessionEpoch == epoch) block() else null }153154 155 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {156 beforeSessionStateMutation()157 return mutateIfCurrentEpoch(epoch, block)158 }159160 private data class PersistSnapshot(161 val epoch: Long,162 val key: ByteArray,163 val payload: VaultPayload,164 val decoy: Boolean,165 )166167 168 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {169 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null170 val liveKey = derivedKey ?: return@mutateState null171 PersistSnapshot(172 epoch = sessionEpoch,173 key = liveKey.copyOf(),174 payload = currentPayloadLocked(),175 decoy = decoyMode,176 )177 }178179 private var derivedKey: ByteArray? = null180 private var rootKey: ByteArray? = null181 private var entryList: MutableList<VaultEntry> = mutableListOf()182 private var duressConfigured: Boolean = false183 private var recoveryWordHash: String? = null184 private var recoveryWordsSet: Boolean = false185 186 private var sessionDataAvailable: Boolean = false187 188 private var decoyMode: Boolean = false189190 private val _isUnlocked = MutableStateFlow(false)191 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()192193194 195196197198199200 private val _uiGateLocked = MutableStateFlow(false)201 val uiGateLocked: StateFlow<Boolean> = _uiGateLocked.asStateFlow()202203 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())204 205 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()206207 private val _settings = MutableStateFlow(VaultSettings())208 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()209210 211 private fun clearSessionStateLocked() {212 derivedKey?.let(VaultCrypto::zero)213 rootKey?.let(VaultCrypto::zero)214 derivedKey = null215 rootKey = null216 entryList = mutableListOf()217 duressConfigured = false218 recoveryWordHash = null219 recoveryWordsSet = false220 sessionDataAvailable = false221 decoyMode = false222 _settings.value = VaultSettings()223 _entries.value = emptyList()224 _isUnlocked.value = false225 }226227 fun hasVault(): Boolean = store.hasVault()228229 230231 232233234235236237238 fun codexVaultBlobBase64(): String? {239 val slotA = store.loadSlotA() ?: return null240 241 242 val slotD = store.loadDecoySlot() ?: return null243 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""244 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))245 }246247 248249250251252 253 private fun JsonElement?.asSlotJson(): String? = when {254 this == null || this is JsonNull -> null255 this is JsonPrimitive && isString -> content256 else -> toString()257 }258259 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {260 val expectedEpoch = currentEpoch()261 var candidateKey: ByteArray? = null262 var plaintext: ByteArray? = null263 return try {264 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)265 266 267 268 269 270 271 272 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject273 ?: return UnlockResult.Error("invalid_codex_blob")274 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")275 276 277 278 279 280 val slotA = VaultCodec.decodeSlot(slotJson)281 ?: BrowserSlot.parse(slotJson)?.let {282 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword283 }284 ?: return UnlockResult.Error("invalid_codex_blob")285 286 287 288 289 290 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))291 plaintext = decryptSlotABlob(slotA, candidateKey)292 293 294 295 296 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {297 VaultCrypto.zero(candidateKey)298 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))299 plaintext = decryptSlotABlob(slotA, candidateKey)300 }301 if (plaintext == null) return UnlockResult.WrongPassword302 val payload = try {303 VaultCodec.decode(plaintext)304 } catch (_: Exception) {305 return UnlockResult.Error("invalid_codex_blob")306 }307308 val priorA = store.loadSlotA()309 val priorB = store.loadSlotB()310 val priorD = store.loadDecoySlot()311 312 313 314 315 316 val codexDuressJson = wrapper["b"].asSlotJson()317 val codexDuress: StoredVault? = codexDuressJson?.let {318 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->319 320 321 322 323 324 325 browserDuressPassthrough(b)326 }327 }328329 beforeDurableCommit()330 val committed = runCatching {331 withEpochCommit(expectedEpoch) {332 try {333 334 335 336 337 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")338 339 340 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")341 342 343 val duressOk = if (codexDuress != null) {344 store.writeDecoySlot(codexDuress)345 } else {346 writeFakeDuressSlot(slotA)347 }348 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")349 store.stampLocalMutation()350 true351 } catch (_: Exception) {352 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))353 354 355 356 afterCodexRollback()357 false358 }359 } ?: false360 }.getOrDefault(false)361 if (!committed) {362 return UnlockResult.Error("storage_write_failed")363 }364365 val ownedKey = candidateKey!!366 candidateKey = null 367 adoptSlotA(ownedKey, payload, expectedEpoch)368 } catch (e: Exception) {369 370 371 372 373 374 375 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))376 UnlockResult.Error(e.message ?: "restore_failed")377 } finally {378 plaintext?.let(VaultCrypto::zero)379 candidateKey?.let(VaultCrypto::zero)380 }381 }382383 384 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =385 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()386387 388389390391392393394395396 private fun rollbackRestoreLocked(397 priorA: StoredVault?,398 priorB: String?,399 priorD: StoredVault? = null,400 ): List<String> {401 val failures = mutableListOf<String>()402 if (priorA == null) {403 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }404 } else {405 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }406 .onFailure { failures += "writeSlotA: $it" }407 if (priorB != null) {408 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }409 .onFailure { failures += "writeSlotB: $it" }410 }411 412 413 414 if (priorD != null) {415 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }416 .onFailure { failures += "writeDecoySlot: $it" }417 } else {418 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }419 }420 }421 return failures422 }423424 private fun reportRollbackFailures(failures: List<String>) {425 426 427 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }428 }429430 431 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {432 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)433 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()434 val window = offset?.let {435 val from = (it - 40).coerceAtLeast(0)436 val to = (it + 40).coerceAtMost(wrapperJson.length)437 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")438 }439 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()440 val slot = wrapper?.get("a")441 buildString {442 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)443 append(" | blobBase64=").append(vaultBlobBase64.length)444 append(" wrapperChars=").append(wrapperJson.length)445 append(" wrapperParses=").append(wrapper != null)446 append(" keys=").append(wrapper?.keys)447 append(" slotAKind=").append(448 when {449 slot == null -> "absent"450 slot is JsonPrimitive && slot.isString -> "string"451 slot is JsonObject -> "object:" + slot.keys452 else -> slot::class.simpleName453 },454 )455 if (window != null) append(" masked@").append(offset).append("=").append(window)456 }457 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }458459 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {460 val expectedEpoch = currentEpoch()461 var key: ByteArray? = null462 return try {463 val salt = VaultCrypto.generateSalt()464 key = VaultCrypto.deriveKey(password, salt)465 if (mutateIfCurrentEpoch(expectedEpoch) {466 entryList = mutableListOf()467 duressConfigured = false468 recoveryWordHash = null469 recoveryWordsSet = false470 decoyMode = false471 sessionDataAvailable = false472 rootKey = generateRecoverableRootKey()473 } == null) return CreateVaultResult.Error("stale_create")474 475 476 477 478 val slotA = sealSlotA(key!!, salt)479 480 481 beforeDurableCommit()482 val created = withEpochCommit(expectedEpoch) {483 if (!store.writeSlotA(slotA)) return@withEpochCommit false484 try {485 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")486 487 488 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")489 store.stampLocalMutation()490 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")491 true492 } catch (_: Exception) {493 494 495 store.clearVault()496 false497 }498 } ?: false499 if (!created) {500 lock()501 return CreateVaultResult.Error("storage_write_failed")502 }503 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")504 if (mutateIfCurrentEpoch(expectedEpoch) {505 derivedKey = sessionKey506 key = null507 publish()508 _isUnlocked.value = true509 sessionDataAvailable = true510 sessionEpoch = nextSessionEpoch(sessionEpoch)511 } == null) return CreateVaultResult.Error("stale_create")512 CreateVaultResult.Success513 } catch (e: Exception) {514 lock()515 CreateVaultResult.Error(e.message ?: "create_failed")516 } finally {517 key?.let(VaultCrypto::zero)518 }519 }520521 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {522 val expectedEpoch = currentEpoch()523 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")524 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")525 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {526 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")527 }528 var adoptedRootKey: ByteArray? = null529 var nextDerivedKey: ByteArray? = null530 var sessionRootKey: ByteArray? = null531 return try {532 adoptedRootKey = hexToRootKey(rootKeyHex)533 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {534 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")535 }536 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)537 val salt = VaultCrypto.generateSalt()538 nextDerivedKey = VaultCrypto.deriveKey(password, salt)539 sessionRootKey = adoptedRootKey.copyOf()540 val payload = VaultPayload(541 entries = emptyList(),542 duressConfigured = false,543 rootKeyB64 = b64(sessionRootKey),544 recoveryWordHash = null,545 recoveryWordsSet = false,546 )547 val plaintext = VaultCodec.encode(payload)548 val blob = try {549 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))550 } finally {551 VaultCrypto.zero(plaintext)552 }553 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)554 555 556 beforeDurableCommit()557 val created = withEpochCommit(expectedEpoch) {558 if (!store.writeSlotA(slotA)) return@withEpochCommit false559 try {560 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")561 562 563 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")564 store.stampLocalMutation()565 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")566 true567 } catch (_: Exception) {568 store.clearVault()569 false570 }571 } ?: false572 if (!created) return AdoptVaultResult.Error("storage_write_failed")573574 val adopted = mutateIfCurrentEpoch(expectedEpoch) {575 val previousDerivedKey = derivedKey576 val previousRootKey = rootKey577 derivedKey = nextDerivedKey578 rootKey = sessionRootKey579 nextDerivedKey = null580 sessionRootKey = null581 entryList = mutableListOf()582 duressConfigured = false583 recoveryWordHash = null584 recoveryWordsSet = false585 decoyMode = false586 sessionDataAvailable = true587 publish()588 _isUnlocked.value = true589 sessionEpoch = nextSessionEpoch(sessionEpoch)590 previousDerivedKey to previousRootKey591 }592 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")593 val (previousDerivedKey, previousRootKey) = adopted594 previousDerivedKey?.let(VaultCrypto::zero)595 previousRootKey?.let(VaultCrypto::zero)596 597 598 599 runCatching {600 val words = rootKeyToWords(rootKey!!)601 val escrow = buildRecoveryEscrow(words)602 val rkEscrow = buildRootKeyEscrow(words)603 if (escrow != null || rkEscrow != null) {604 val prior = store.loadSlotA()605 if (prior != null) {606 store.writeSlotA(prior.copy(607 recoveryEscrow = escrow ?: prior.recoveryEscrow,608 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,609 ))610 }611 mutateState {612 recoveryWordHash = recoveryWordsHash(words)613 recoveryWordsSet = true614 }615 }616 }617 AdoptVaultResult.Success(vaultId)618 } catch (e: Exception) {619 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")620 } finally {621 adoptedRootKey?.let(VaultCrypto::zero)622 nextDerivedKey?.let(VaultCrypto::zero)623 sessionRootKey?.let(VaultCrypto::zero)624 }625 }626627 suspend fun unlock(password: String): UnlockResult = mutex.withLock {628 val result = unlockLocked(password, currentEpoch())629 if (result is UnlockResult.Success) ensureRecoveryEscrow()630 result631 }632633 634 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {635 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")636 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))637 val payload = try {638 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)639 try {640 VaultCodec.decode(plaintext)641 } finally {642 VaultCrypto.zero(plaintext)643 }644 } catch (_: Exception) {645 VaultCrypto.zero(key)646 647 648 649 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {650 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)651 if (legacy != null) return legacy652 }653 return UnlockResult.WrongPassword654 }655 return adoptSlotA(key, payload, expectedEpoch)656 }657658 659660661662663664665666 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {667 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))668 var newKey: ByteArray? = null669 return try {670 val payload = try {671 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)672 try {673 VaultCodec.decode(plaintext)674 } finally {675 VaultCrypto.zero(plaintext)676 }677 } catch (_: Exception) {678 return null679 }680 681 682 val newSalt = VaultCrypto.generateSalt()683 newKey = VaultCrypto.deriveKey(password, newSalt)684 val plaintext = VaultCodec.encode(payload)685 val blob = try {686 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))687 } finally {688 VaultCrypto.zero(plaintext)689 }690 val next = StoredVault(691 version = slotA.version,692 salt = b64(newSalt),693 blob = blob,694 recoveryEscrow = slotA.recoveryEscrow,695 rootKeyEscrow = slotA.rootKeyEscrow,696 )697 698 699 700 val priorB = store.loadSlotB()701 beforeDurableCommit()702 val healed = withEpochCommit(expectedEpoch) {703 if (!store.writeSlotA(next)) {704 false705 } else try {706 if (!writeMatchingNoiseSlotB(next)) {707 runCatching { store.writeSlotA(slotA) }708 if (priorB != null) runCatching { store.writeSlotB(priorB) }709 false710 } else if (!writeCompanionDuressSlot(next)) {711 runCatching { store.writeSlotA(slotA) }712 if (priorB != null) runCatching { store.writeSlotB(priorB) }713 false714 } else {715 store.stampLocalMutation()716 true717 }718 } catch (_: Exception) {719 runCatching { store.writeSlotA(slotA) }720 if (priorB != null) runCatching { store.writeSlotB(priorB) }721 false722 }723 } ?: false724 if (healed) {725 VaultCrypto.zero(rawKey!!)726 rawKey = null727 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 728 newKey = null729 result730 } else {731 VaultCrypto.zero(newKey!!)732 newKey = null733 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 734 rawKey = null735 result736 }737 } finally {738 739 740 rawKey?.let(VaultCrypto::zero)741 newKey?.let(VaultCrypto::zero)742 }743 }744745 746 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {747 return try {748 749 750 751 752 753 val adopted = mutateIfCurrentEpoch(expectedEpoch) {754 derivedKey = key755 decoyMode = false756 entryList = payload.entries.toMutableList()757 duressConfigured = payload.duressConfigured758 recoveryWordHash = payload.recoveryWordHash759 recoveryWordsSet = payload.recoveryWordsSet760 sessionDataAvailable = true761 _settings.value = store.loadSettings()762 ensureRootKey(payload)763 autoPurgeExpiredTrash()764 consolidatePasskeyEntries()765 publish()766 _isUnlocked.value = true767 768 769 770 sessionEpoch = nextSessionEpoch(sessionEpoch)771 }772 if (adopted == null) {773 VaultCrypto.zero(key)774 UnlockResult.Error("stale_unlock")775 } else {776 UnlockResult.Success777 }778 } catch (e: Exception) {779 780 781 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }782 VaultCrypto.zero(key)783 UnlockResult.Error(e.message ?: "unlock_failed")784 }785 }786787 788789 790791792793794795 class SlotAUnlock internal constructor(796 key: ByteArray,797 internal val payload: VaultPayload,798 internal val epoch: Long,799 ) {800 private var ownedKey: ByteArray? = key801 private var consumed = false802803 804 internal fun takeKey(): ByteArray? = synchronized(this) {805 if (consumed) return@synchronized null806 consumed = true807 ownedKey.also { ownedKey = null }808 }809810 811 internal fun discard() = synchronized(this) {812 if (consumed) return@synchronized813 consumed = true814 ownedKey?.let(VaultCrypto::zero)815 ownedKey = null816 }817 }818819 820821822823824825 fun tryUnlockSlotA(password: String): SlotAUnlock? {826 val epoch = currentEpoch()827 val slotA = store.loadSlotA() ?: return null828 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))829 return try {830 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)831 val payload = try {832 VaultCodec.decode(plaintext)833 } finally {834 VaultCrypto.zero(plaintext)835 }836 SlotAUnlock(key, payload, epoch)837 } catch (_: Exception) {838 VaultCrypto.zero(key)839 null840 }841 }842843 844 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {845 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")846 adoptSlotA(key, unlock.payload, unlock.epoch)847 }848849 850 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()851852 853854 855856857858859 class DuressUnlock internal constructor(860 key: ByteArray,861 internal val payload: VaultPayload,862 internal val epoch: Long,863 ) {864 private var ownedKey: ByteArray? = key865 private var consumed = false866867 internal fun takeKey(): ByteArray? = synchronized(this) {868 if (consumed) return@synchronized null869 consumed = true870 ownedKey.also { ownedKey = null }871 }872873 internal fun discard() = synchronized(this) {874 if (consumed) return@synchronized875 consumed = true876 ownedKey?.let(VaultCrypto::zero)877 ownedKey = null878 }879 }880881 882883884885886887888 fun tryUnlockDuress(password: String): DuressUnlock? {889 val epoch = currentEpoch()890 val slot = store.loadDecoySlot() ?: return null891 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))892 return try {893 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)894 val payload = try {895 VaultCodec.decode(plaintext)896 } finally {897 VaultCrypto.zero(plaintext)898 }899 DuressUnlock(key, payload, epoch)900 } catch (_: Exception) {901 VaultCrypto.zero(key)902 null903 }904 }905906 907 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {908 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")909 if (currentEpoch() != unlock.epoch) {910 VaultCrypto.zero(key)911 return@withLock UnlockResult.Error("stale_unlock")912 }913 return try {914 val adopted = mutateIfCurrentEpoch(unlock.epoch) {915 derivedKey = key916 decoyMode = true917 entryList = unlock.payload.entries.toMutableList()918 duressConfigured = false919 recoveryWordHash = unlock.payload.recoveryWordHash920 recoveryWordsSet = unlock.payload.recoveryWordsSet921 sessionDataAvailable = true922 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)923 else VaultCrypto.generateRootKey()924 _settings.value = store.loadSettings()925 publish()926 _isUnlocked.value = true927 sessionEpoch = nextSessionEpoch(sessionEpoch)928 }929 if (adopted == null) {930 VaultCrypto.zero(key)931 UnlockResult.Error("stale_unlock")932 } else {933 UnlockResult.Success934 }935 } catch (e: Exception) {936 937 938 939 940 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }941 VaultCrypto.zero(key)942 UnlockResult.Error(e.message ?: "decoy_unlock_failed")943 }944 }945946 947 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()948949 950951952953954955 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {956 957 val expectedEpoch = mutateState {958 if (derivedKey != null && !decoyMode) sessionEpoch else null959 } ?: return false960 961 962 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }963 var key: ByteArray? = null964 var basePlaintext: ByteArray? = null965 return try {966 val salt = VaultCrypto.generateSalt()967 key = VaultCrypto.deriveKey(duressPassphrase, salt)968 969 val decoyRoot = VaultCrypto.generateRootKey()970 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }971 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)972 basePlaintext = VaultCodec.encode(decoyPayload)973 974 975 976 977 978 979 980 981 val slotA = store.loadSlotA()982 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0983 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!984 val blob = try {985 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))986 } finally {987 VaultCrypto.zero(plaintext)988 989 990 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)991 basePlaintext = null992 key?.let(VaultCrypto::zero)993 key = null994 }995 beforeDurableCommit()996 val ok = withEpochCommit(expectedEpoch) {997 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))998 } ?: false999 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {1000 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1001 } else if (ok) {1002 return false1003 }1004 ok1005 } catch (_: Exception) {1006 false1007 } finally {1008 1009 1010 key?.let(VaultCrypto::zero)1011 basePlaintext?.let(VaultCrypto::zero)1012 }1013 }101410151016 101710181019102010211022102310241025 suspend fun exportDuressSlotForAdoption(): String? = mutex.withLock {1026 if (!duressConfigured) return null1027 val slot = store.loadDecoySlot() ?: return null1028 return runCatching { androidDuressSlotToBrowserJson(slot) }.getOrNull()1029 }10301031 1032103310341035103610371038 private fun androidDuressSlotToBrowserJson(slot: StoredVault): String {1039 1040 1041 1042 1043 1044 var salt: ByteArray? = null1045 var blob: ByteArray? = null1046 var nonce: ByteArray? = null1047 var ctAndTag: ByteArray? = null1048 var ct: ByteArray? = null1049 var tag: ByteArray? = null1050 try {1051 salt = unb64(slot.salt)1052 blob = unb64(slot.blob)1053 nonce = blob.copyOfRange(0, VaultCrypto.NONCE_SIZE)1054 ctAndTag = blob.copyOfRange(VaultCrypto.NONCE_SIZE, blob.size)1055 ct = ctAndTag.copyOfRange(0, ctAndTag.size - 16)1056 tag = ctAndTag.copyOfRange(ctAndTag.size - 16, ctAndTag.size)1057 val obj = JsonObject(mapOf(1058 "version" to JsonPrimitive(1),1059 "salt" to JsonPrimitive(salt.joinToString("") { "%02x".format(it) }),1060 "nonce" to JsonPrimitive(nonce.joinToString("") { "%02x".format(it) }),1061 "ciphertext" to JsonPrimitive(ct.joinToString("") { "%02x".format(it) }),1062 "tag" to JsonPrimitive(tag.joinToString("") { "%02x".format(it) }),1063 ))1064 return obj.toString()1065 } finally {1066 salt?.let(VaultCrypto::zero)1067 blob?.let(VaultCrypto::zero)1068 nonce?.let(VaultCrypto::zero)1069 ctAndTag?.let(VaultCrypto::zero)1070 ct?.let(VaultCrypto::zero)1071 tag?.let(VaultCrypto::zero)1072 }1073 }10741075 1076107710781079108010811082108310841085 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1086 val b = BrowserSlot.parse(json) ?: return false1087 var stored: StoredVault? = null1088 var salt: ByteArray? = null1089 var nonce: ByteArray? = null1090 var ct: ByteArray? = null1091 var tag: ByteArray? = null1092 var blob: ByteArray? = null1093 try {1094 salt = unhex(b.salt)1095 nonce = unhex(b.nonce)1096 ct = unhex(b.ciphertext)1097 tag = unhex(b.tag)1098 blob = ByteArray(nonce.size + ct.size + tag.size).also {1099 System.arraycopy(nonce, 0, it, 0, nonce.size)1100 System.arraycopy(ct, 0, it, nonce.size, ct.size)1101 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1102 }1103 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1104 val expectedEpoch = sessionEpoch1105 beforeDurableCommit()1106 withEpochCommit(expectedEpoch) {1107 store.writeDecoySlot(stored)1108 } ?: false1109 } catch (_: Exception) {1110 false1111 } finally {1112 salt?.let(VaultCrypto::zero)1113 nonce?.let(VaultCrypto::zero)1114 ct?.let(VaultCrypto::zero)1115 tag?.let(VaultCrypto::zero)1116 blob?.let(VaultCrypto::zero)1117 }1118 }11191120 1121 suspend fun clearDuress(): Boolean = mutex.withLock {1122 1123 1124 1125 1126 val expectedEpoch = currentEpoch()1127 val replaced = withEpochCommit(expectedEpoch) {1128 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1129 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1130 } ?: false1131 if (!replaced) return@withLock false1132 1133 1134 if (!isSessionEpochCurrent(expectedEpoch)) return false1135 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1136 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1137 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1138 }1139 true1140 }11411142 1143 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1144 var current: Throwable? = error1145 while (current != null) {1146 if (current is javax.crypto.AEADBadTagException) return true1147 1148 1149 1150 1151 if (current is javax.crypto.BadPaddingException) return true1152 current = current.cause1153 }1154 return false1155 }11561157 1158 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1159 val expectedEpoch = currentEpoch()1160 val slotA = try {1161 store.loadSlotA()1162 } catch (_: Exception) {1163 return@withLock UnlockResult.Error("unlock_key_storage_failed")1164 } ?: return@withLock UnlockResult.Error("no_vault")1165 1166 1167 1168 1169 var stagedKey: ByteArray? = key.copyOf()1170 return try {1171 val encrypted = try {1172 unb64(slotA.blob)1173 } catch (_: Exception) {1174 return@withLock UnlockResult.Error("invalid_vault_blob")1175 }1176 1177 1178 1179 1180 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1181 return@withLock UnlockResult.Error("invalid_vault_blob")1182 }1183 val plaintext = try {1184 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1185 } catch (e: Exception) {1186 if (isGcmAuthenticationFailure(e)) {1187 return@withLock UnlockResult.WrongPassword1188 }1189 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1190 }1191 val payload = try {1192 VaultCodec.decode(plaintext)1193 } catch (_: Exception) {1194 return@withLock UnlockResult.Error("invalid_vault_payload")1195 } finally {1196 VaultCrypto.zero(plaintext)1197 }1198 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1199 val result = adoptSlotA(candidate, payload, expectedEpoch)1200 if (result is UnlockResult.Success) { stagedKey = null; ensureRecoveryEscrow() }1201 result1202 } finally {1203 stagedKey?.let(VaultCrypto::zero)1204 }1205 }12061207 fun lock() {1208 1209 1210 1211 1212 if (mutex.tryLock()) {1213 try {1214 runCatching { LockedSyncSpoolHooks.refresh(this) }1215 } finally {1216 mutex.unlock()1217 }1218 }1219 1220 1221 1222 1223 1224 1225 stateLock.lock()1226 try {1227 sessionEpoch = nextSessionEpoch(sessionEpoch)1228 clearSessionStateLocked()1229 _uiGateLocked.value = false1230 } finally {1231 stateLock.unlock()1232 }1233 }12341235 1236123712381239124012411242 fun lockUiGate() {1243 if (_isUnlocked.value) _uiGateLocked.value = true1244 }12451246 1247 fun clearUiGate() {1248 _uiGateLocked.value = false1249 }12501251 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L12521253 1254 1255 @VisibleForTesting1256 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }12571258 125912601261126212631264126512661267126812691270 fun expectTransientBackground() {1271 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1272 }12731274 12751276127712781279 fun consumeTransientBackgroundSuppression(): Boolean {1280 val until = suppressNextBackgroundLockUntil1281 val active = until != 0L && nowMs() < until1282 suppressNextBackgroundLockUntil = 0L1283 return active1284 }12851286 12871288 12891290129112921293 12941295129612971298129913001301130213031304 1305 1306 1307 1308 1309 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)13101311 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1312 if (decoyMode || !_isUnlocked.value) null1313 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1314 }13151316 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1317 mutateState { sessionEpoch == epoch }13181319 internal fun unlockedSessionEpoch(): Long? = mutateState {1320 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1321 }13221323 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key13241325 132613271328132913301331133213331334 fun sessionRootKey(): ByteArray? = mutateState {1335 if (!sessionDataAvailable) null else rootKey?.copyOf()1336 }13371338 13391340 fun syncVaultId(): String? = mutateState {1341 if (decoyMode || !_isUnlocked.value) null1342 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1343 }13441345 13461347134813491350 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1351 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1352 ?: return false1353 installRootKey(generateRecoverableRootKey(), expectedEpoch)1354 }13551356 13571358135913601361136213631364136513661367136813691370137113721373 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1374 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1375 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1376 }1377 val expectedEpoch = mutateState {1378 when {1379 derivedKey == null -> null1380 decoyMode -> Long.MIN_VALUE1381 else -> sessionEpoch1382 }1383 }1384 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1385 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1386 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")13871388 val incoming = hexToRootKey(rootKeyHex)1389 if (incoming.size != VaultCrypto.KEY_SIZE) {1390 VaultCrypto.zero(incoming)1391 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1392 }1393 1394 1395 1396 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1397 if (!roundTrips) {1398 VaultCrypto.zero(incoming)1399 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1400 }14011402 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1403 return if (installRootKey(incoming, expectedEpoch)) {1404 AdoptVaultResult.Success(vaultId)1405 } else {1406 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1407 }1408 }14091410 14111412141314141415 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1416 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1417 val bytes = hexToRootKey(rootKeyHex)1418 return try {1419 val words = rootKeyToWords(bytes)1420 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1421 } catch (_: Exception) {1422 null1423 } finally {1424 VaultCrypto.zero(bytes)1425 }1426 }14271428 14291430143114321433 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1434 1435 1436 1437 1438 1439 1440 data class Snapshot(1441 val key: ByteArray,1442 val priorRootKey: ByteArray,1443 val priorRecoveryHash: String?,1444 val priorRecoverySet: Boolean,1445 )1446 val snap = mutateIfCurrentEpoch(expectedEpoch) {1447 val k = derivedKey ?: return@mutateIfCurrentEpoch null1448 if (decoyMode) return@mutateIfCurrentEpoch null1449 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1450 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1451 } ?: run {1452 VaultCrypto.zero(nextRootKey)1453 return false1454 }1455 val priorSlot = store.loadSlotA() ?: run {1456 VaultCrypto.zero(snap.key)1457 VaultCrypto.zero(snap.priorRootKey)1458 VaultCrypto.zero(nextRootKey)1459 return false1460 }1461 val nextWords = rootKeyToWords(nextRootKey)14621463 return try {1464 beforeDurableCommit()1465 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1466 if (mutateIfCurrentEpoch(expectedEpoch) {1467 rootKey = nextRootKey1468 recoveryWordHash = recoveryWordsHash(nextWords)1469 recoveryWordsSet = false1470 } == null) throw IllegalStateException("stale_root_rotation")14711472 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1473 ?: throw IllegalStateException("recovery_escrow_failed")1474 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1475 ?: throw IllegalStateException("root_escrow_failed")1476 val plaintext = VaultCodec.encode(currentPayload())1477 val blob = try {1478 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1479 } finally {1480 VaultCrypto.zero(plaintext)1481 }1482 val nextSlot = priorSlot.copy(1483 blob = blob,1484 recoveryEscrow = nextRecoveryEscrow,1485 rootKeyEscrow = nextRootKeyEscrow,1486 )1487 val committed = withEpochCommit(expectedEpoch) {1488 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1489 try {1490 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1491 1492 1493 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1494 store.stampLocalMutation()1495 } catch (_: Exception) {1496 store.writeSlotA(priorSlot)1497 return@withEpochCommit false1498 }1499 true1500 } ?: false1501 if (!committed) throw IllegalStateException("storage_write_failed")1502 mutateIfCurrentEpoch(expectedEpoch) { publish() }1503 true1504 } catch (_: Exception) {1505 val restored = mutateIfCurrentEpoch(expectedEpoch) {1506 rootKey?.let(VaultCrypto::zero)1507 rootKey = snap.priorRootKey.copyOf()1508 recoveryWordHash = snap.priorRecoveryHash1509 recoveryWordsSet = snap.priorRecoverySet1510 }1511 if (restored != null) {1512 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1513 }1514 false1515 } finally {1516 VaultCrypto.zero(snap.key)1517 VaultCrypto.zero(snap.priorRootKey)1518 1519 1520 1521 val stillOwnedBySession = mutateState {1522 sessionEpoch == expectedEpoch && rootKey === nextRootKey1523 }1524 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1525 }1526 }15271528 15291530153115321533 fun currentVaultKey(): ByteArray? = mutateState {1534 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1535 }15361537 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1538 val expectedEpoch = currentEpoch()1539 val slotA = store.loadSlotA() ?: return false1540 var oldKey: ByteArray? = null1541 var newKey: ByteArray? = null1542 var plaintext: ByteArray? = null1543 return try {1544 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1545 1546 1547 1548 1549 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1550 val payload = try {1551 VaultCodec.decode(decrypted)1552 } finally {1553 VaultCrypto.zero(decrypted)1554 }1555 val newSalt = VaultCrypto.generateSalt()1556 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1557 1558 plaintext = VaultCodec.encode(payload)1559 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1560 1561 1562 1563 1564 1565 1566 1567 val next = StoredVault(1568 version = slotA.version,1569 salt = b64(newSalt),1570 blob = blob,1571 recoveryEscrow = slotA.recoveryEscrow,1572 rootKeyEscrow = slotA.rootKeyEscrow,1573 )1574 1575 1576 1577 1578 val priorB = store.loadSlotB()1579 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1580 1581 1582 1583 if (mutateIfCurrentEpoch(expectedEpoch) {1584 oldKey?.let(VaultCrypto::zero); oldKey = null1585 derivedKey?.let(VaultCrypto::zero)1586 derivedKey = newKey; newKey = null1587 } == null) return false1588 true1589 } catch (_: Exception) {1590 false1591 } finally {1592 oldKey?.let(VaultCrypto::zero)1593 newKey?.let(VaultCrypto::zero)1594 plaintext?.let(VaultCrypto::zero)1595 }1596 }15971598 1599160016011602 suspend fun destroyVault() = mutex.withLock {1603 lock()1604 store.clearVault()1605 store.clearDecoySlot()1606 }16071608 16091610 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1611 val epoch = requireUnlocked()1612 val stamp = VaultCrypto.monotonicEntryTimestamp()1613 val now = System.currentTimeMillis()1614 val entry = withDerivedLast4(draft.copy(1615 id = draft.id.ifBlank { UUID.randomUUID().toString() },1616 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1617 modifiedAt = stamp,1618 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1619 ))1620 1621 1622 val rollback = mutateCurrentSession(epoch) {1623 val r = entryList.toMutableList()1624 entryList.add(entry)1625 r1626 } ?: throw IllegalStateException("vault_locked")1627 persistOrRollback(rollback, epoch)1628 entry1629 }16301631 163216331634163516361637163816391640 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1641 val epoch = requireUnlocked()1642 if (drafts.isEmpty()) return 01643 val now = System.currentTimeMillis()1644 val ready = drafts.map { draft ->1645 val stamp = VaultCrypto.monotonicEntryTimestamp()1646 withDerivedLast4(1647 draft.copy(1648 id = draft.id.ifBlank { UUID.randomUUID().toString() },1649 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1650 modifiedAt = stamp,1651 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1652 ),1653 )1654 }1655 1656 val rollback = mutateCurrentSession(epoch) {1657 val r = entryList.toMutableList()1658 entryList.addAll(ready)1659 r1660 } ?: throw IllegalStateException("vault_locked")1661 persistOrRollback(rollback, epoch)1662 return drafts.size1663 }16641665 1666166716681669167016711672167316741675167616771678 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1679 val epoch = requireUnlocked()1680 val now = System.currentTimeMillis()1681 val ready = newEntries.map { draft ->1682 val stamp = VaultCrypto.monotonicEntryTimestamp()1683 withDerivedLast4(1684 draft.copy(1685 id = draft.id.ifBlank { UUID.randomUUID().toString() },1686 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1687 modifiedAt = stamp,1688 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1689 ),1690 )1691 }1692 1693 1694 1695 1696 1697 1698 val incomingIds = ready.map { it.id }.toHashSet()1699 val rollback = mutateCurrentSession(epoch) {1700 val r = entryList.toMutableList()1701 val replaced = entryList.map { entry ->1702 if (entry.deletedAt != null) return@map entry1703 if (entry.id in incomingIds) return@map null1704 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1705 entry.copy(deletedAt = stamp, modifiedAt = stamp)1706 }.filterNotNull()1707 entryList = (replaced + ready).toMutableList()1708 r1709 } ?: throw IllegalStateException("vault_locked")1710 persistOrRollback(rollback, epoch)1711 return ready.size1712 }17131714 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1715 val epoch = requireUnlocked()1716 1717 1718 1719 1720 val prepared = mutateCurrentSession(epoch) {1721 val idx = entryList.indexOfFirst { it.id == patch.id }1722 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1723 val current = entryList[idx]1724 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1725 1726 val passwordChanged = patch.password != null && patch.password != current.password1727 1728 1729 1730 1731 1732 1733 val priorPassword = current.password1734 val shouldCapture = passwordChanged &&1735 !priorPassword.isNullOrEmpty() &&1736 current.passwordHistory?.firstOrNull()?.password != priorPassword1737 val nextHistory = if (shouldCapture) {1738 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1739 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1740 } else {1741 current.passwordHistory1742 }1743 val updated = withDerivedLast4(patch.copy(1744 createdAt = current.createdAt,1745 modifiedAt = stamp,1746 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1747 passwordHistory = nextHistory,1748 ))1749 val rollback = entryList.toMutableList()1750 entryList[idx] = updated1751 updated to rollback1752 } ?: throw IllegalStateException("vault_locked")1753 val (updated, rollback) = prepared1754 persistOrRollback(rollback, epoch)1755 updated1756 }17571758 1759 suspend fun deleteEntry(id: String) = mutex.withLock {1760 val epoch = requireUnlocked()1761 1762 1763 val rollback = mutateCurrentSession(epoch) {1764 val idx = entryList.indexOfFirst { it.id == id }1765 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1766 val current = entryList[idx]1767 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1768 val prior = entryList.toMutableList()1769 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1770 prior1771 } ?: throw IllegalStateException("vault_locked")1772 persistOrRollback(rollback, epoch)1773 }17741775 suspend fun restoreEntry(id: String) = mutex.withLock {1776 val epoch = requireUnlocked()1777 val rollback = mutateCurrentSession(epoch) {1778 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1779 if (idx == -1) throw IllegalStateException("deleted entry not found")1780 val current = entryList[idx]1781 val prior = entryList.toMutableList()1782 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1783 prior1784 } ?: throw IllegalStateException("vault_locked")1785 persistOrRollback(rollback, epoch)1786 }17871788 1789 suspend fun purgeEntry(id: String) = mutex.withLock {1790 val epoch = requireUnlocked()1791 val rollback = mutateCurrentSession(epoch) {1792 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1793 if (idx == -1) throw IllegalStateException("deleted entry not found")1794 val prior = entryList.toMutableList()1795 entryList.removeAt(idx)1796 prior1797 } ?: throw IllegalStateException("vault_locked")1798 persistOrRollback(rollback, epoch)1799 }18001801 1802 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1803 val epoch = requireUnlocked()1804 1805 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1806 val r = mutateCurrentSession(epoch) {1807 val rollback = entryList.toMutableList()1808 val before = entryList.size1809 entryList.removeAll { it.deletedAt != null }1810 R(rollback, before - entryList.size)1811 } ?: throw IllegalStateException("vault_locked")1812 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1813 r.removed1814 }18151816 1817 suspend fun countDuplicateEntries(): Int = mutex.withLock {1818 val epoch = requireUnlocked()1819 1820 1821 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1822 ?: throw IllegalStateException("vault_locked")1823 }18241825 18261827182818291830 suspend fun deduplicateEntries(): Int = mutex.withLock {1831 val epoch = requireUnlocked()1832 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1833 val ready = mutateCurrentSession(epoch) {1834 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1835 val active = entryList.filter { it.deletedAt == null }1836 val keepIds = mutableSetOf<String>()1837 active.groupBy { it.duplicateKey() }.values.forEach { group ->1838 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1839 }1840 val rollback = entryList.toMutableList()1841 var removed = 01842 entryList = entryList.map { entry ->1843 if (entry.deletedAt == null && entry.id !in keepIds) {1844 removed++1845 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1846 entry.copy(deletedAt = stamp, modifiedAt = stamp)1847 } else {1848 entry1849 }1850 }.toMutableList()1851 R(rollback, removed)1852 } ?: throw IllegalStateException("vault_locked")1853 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1854 ready.removed1855 }18561857 fun getEntries(): List<VaultEntry> = mutateState {1858 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1859 }1860 fun getEntry(id: String): VaultEntry? = mutateState {1861 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1862 }1863 fun getDeletedEntries(): List<VaultEntry> = mutateState {1864 if (!sessionDataAvailable) emptyList()1865 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1866 }18671868 1869 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1870 return mutateState {1871 if (!sessionDataAvailable) return@mutateState emptyList()1872 val active = entryList.filter { it.deletedAt == null }1873 if (tagFilter == null) return@mutateState active1874 val needle = tagFilter.trim().lowercase()1875 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1876 }1877 }18781879 1880 fun getDistinctTags(): List<String> = mutateState {1881 if (!sessionDataAvailable) return@mutateState emptyList()1882 entryList.asSequence()1883 .filter { it.deletedAt == null }1884 .flatMap { it.tags?.asSequence() ?: emptySequence() }1885 .map { it.trim().lowercase() }1886 .filter { it.isNotEmpty() }1887 .distinct()1888 .sorted()1889 .toList()1890 }18911892 189318941895189618971898 fun getDistinctFieldValues(field: String): List<String> {1899 val select: (VaultEntry) -> String? = when (field) {1900 "username" -> { e -> e.username }1901 "email" -> { e -> e.email }1902 else -> return emptyList()1903 }1904 return mutateState {1905 if (!sessionDataAvailable) return@mutateState emptyList()1906 entryList.asSequence()1907 .filter { it.deletedAt == null }1908 .mapNotNull { select(it)?.trim() }1909 .filter { it.isNotEmpty() }1910 .groupingBy { it.lowercase() }1911 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1912 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1913 }1914 .values1915 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1916 .map { it.first }1917 }1918 }19191920 fun getTombstones(): List<Tombstone> = mutateState {1921 if (!sessionDataAvailable) emptyList()1922 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1923 }1924 fun getVaultVersion(): Long = store.version()1925 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots19261927 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1928 val expectedEpoch = currentEpoch()1929 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1930 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1931 1932 1933 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1934 val priorA = store.loadSlotA()1935 val priorB = store.loadSlotB()1936 val priorD = store.loadDecoySlot()1937 1938 1939 1940 1941 1942 val priorHistory = store.loadVaultHistory()1943 1944 1945 1946 1947 beforeDurableCommit()1948 if (!takeVaultSnapshot("restore", expectedEpoch)) {1949 return false1950 }1951 1952 1953 1954 val nextD: StoredVault = restoredD ?: run {1955 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161956 val fakeBlob = ByteArray(target).also(rng::nextBytes)1957 val fakeSalt = VaultCrypto.generateSalt()1958 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1959 }1960 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1961 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1962 return false1963 }1964 lock()1965 true1966 }19671968 19691970197119721973197419751976197719781979 private fun commitRestoreAtomic(1980 nextA: StoredVault,1981 nextB: String,1982 nextD: StoredVault,1983 priorA: StoredVault?,1984 priorB: String?,1985 priorD: StoredVault?,1986 expectedEpoch: Long,1987 ): Boolean {1988 return withEpochCommit(expectedEpoch) {1989 if (!store.writeSlotA(nextA)) return@withEpochCommit false1990 1991 1992 1993 try {1994 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1995 1996 1997 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1998 store.stampLocalMutation()1999 } catch (_: Exception) {2000 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))2001 return@withEpochCommit false2002 }2003 true2004 } ?: false2005 }20062007 20082009 20102011201220132014 suspend fun applyMergedRemoteEntries(2015 remoteEntries: List<VaultEntry>,2016 remoteTombstones: List<Tombstone>,2017 expectedEpoch: Long? = null,2018 ): Int = mutex.withLock {2019 val epoch = expectedEpoch ?: requireUnlocked()2020 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 02021 2022 2023 2024 2025 2026 val mergeNow = System.currentTimeMillis()2027 val clamped = remoteEntries.mapNotNull { entry ->2028 2029 2030 2031 SyncEntrySanitizer.sanitize(entry, PASSWORD_HISTORY_MAX)?.copy(2032 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),2033 deletedAt = entry.deletedAt?.let {2034 SyncLimits.clampSyncTimestamp(it, mergeNow)2035 },2036 )2037 }2038 val tombMap = remoteTombstones.associate {2039 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)2040 }2041 2042 2043 val applied = mutateCurrentSession(epoch) {2044 var n = 02045 val byId = entryList.associateBy { it.id }.toMutableMap()20462047 for (remote in clamped) {2048 val tomb = tombMap[remote.id]2049 if (tomb != null && tomb >= remote.modifiedAt) continue2050 val local = byId[remote.id]2051 if (local == null) {2052 entryList.add(remote); byId[remote.id] = remote; n++2053 } else if (remote.modifiedAt > local.modifiedAt) {2054 val i = entryList.indexOfFirst { it.id == local.id }2055 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }2056 }2057 }20582059 for ((id, deletedAt) in tombMap) {2060 val local = byId[id] ?: continue2061 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue2062 if (deletedAt < local.modifiedAt) continue2063 val i = entryList.indexOfFirst { it.id == id }2064 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }2065 }2066 n2067 } ?: return@withLock 020682069 if (applied > 0) {2070 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)2071 mutateIfCurrentEpoch(epoch) { publish() }2072 runCatching { LockedSyncSpoolHooks.refresh(this) }2073 }2074 applied2075 }20762077 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {2078 2079 2080 2081 val epoch = requireUnlocked()2082 val prior = mutateCurrentSession(epoch) {2083 val old = _settings.value2084 _settings.value = settings2085 old2086 } ?: throw IllegalStateException("vault_locked")20872088 val committed = try {2089 beforeDurableCommit()2090 2091 2092 2093 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true2094 } catch (_: Exception) {2095 false2096 }2097 if (!committed) {2098 2099 2100 2101 mutateIfCurrentEpoch(epoch) { _settings.value = prior }2102 throw IllegalStateException("settings_write_failed")2103 }2104 }21052106 21072108 2109 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }21102111 21122113211421152116 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {2117 val epoch = requireUnlocked()2118 2119 2120 2121 2122 2123 2124 val prior = mutateCurrentSession(epoch) {2125 val priorPair = recoveryWordHash to recoveryWordsSet2126 recoveryWordHash = recoveryWordsHash(words)2127 recoveryWordsSet = true2128 priorPair2129 } ?: return@withLock false2130 2131 2132 2133 2134 val persisted = runCatching {2135 persistSlotAWithEscrows(2136 buildRecoveryEscrow(words),2137 buildRootKeyEscrow(words),2138 expectedEpoch = epoch,2139 )2140 }.getOrDefault(false)2141 if (!persisted) {2142 mutateIfCurrentEpoch(epoch) {2143 recoveryWordHash = prior.first2144 recoveryWordsSet = prior.second2145 }2146 }2147 persisted2148 }21492150 2151 fun verifyRecoveryWords(words: List<String>): Boolean {2152 return mutateState {2153 val stored = recoveryWordHash ?: return@mutateState false2154 stored == recoveryWordsHash(words)2155 }2156 }21572158 21592160 2161 fun hasRecoveryEscrow(): Boolean =2162 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false21632164 21652166216721682169 private fun ensureRecoveryEscrow() {2170 if (hasRecoveryEscrow()) return2171 val rk = mutateState { rootKey?.copyOf() } ?: return2172 runCatching {2173 val words = rootKeyToWords(rk)2174 val escrow = buildRecoveryEscrow(words)2175 val rkEscrow = buildRootKeyEscrow(words)2176 if (escrow != null || rkEscrow != null) {2177 val prior = store.loadSlotA() ?: return@runCatching2178 store.writeSlotA(prior.copy(2179 recoveryEscrow = escrow ?: prior.recoveryEscrow,2180 rootKeyEscrow = rkEscrow ?: prior.rootKeyEscrow,2181 ))2182 mutateState {2183 recoveryWordHash = recoveryWordsHash(words)2184 recoveryWordsSet = true2185 }2186 }2187 }.also { VaultCrypto.zero(rk) }2188 }21892190 219121922193219421952196219721982199220022012202 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2203 val expectedEpoch = currentEpoch()2204 val slotA = store.loadSlotA() ?: return false2205 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false22062207 2208 2209 val fullEscrow = slotA.recoveryEscrow2210 if (fullEscrow != null) {2211 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2212 if (material != null) {2213 var keyBytes: ByteArray? = null2214 var plaintext: ByteArray? = null2215 var candidateRoot: ByteArray? = null2216 var stateCommitStarted = false2217 try {2218 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2219 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2220 val payload = VaultCodec.decode(plaintext)2221 2222 2223 2224 2225 val candidateSettings = store.loadSettings()2226 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2227 val decodedRoot = unb64(payload.rootKeyB64)2228 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2229 2230 2231 2232 VaultCrypto.zero(decodedRoot)2233 throw IllegalArgumentException("invalid recovery root key")2234 }2235 decodedRoot2236 } else {2237 generateRecoverableRootKey()2238 }2239 stateCommitStarted = true2240 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2241 clearSessionStateLocked()2242 entryList = payload.entries.toMutableList()2243 duressConfigured = payload.duressConfigured2244 recoveryWordHash = payload.recoveryWordHash2245 recoveryWordsSet = payload.recoveryWordsSet2246 rootKey = candidateRoot2247 candidateRoot = null2248 _settings.value = candidateSettings2249 publish()2250 2251 2252 sessionDataAvailable = true2253 _isUnlocked.value = false2254 true2255 }2256 if (adopted == true) return@withLock true2257 } catch (_: Exception) {2258 2259 2260 2261 2262 if (stateCommitStarted) {2263 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2264 }2265 } finally {2266 plaintext?.let(VaultCrypto::zero)2267 VaultCrypto.zero(material)2268 keyBytes?.let(VaultCrypto::zero)2269 candidateRoot?.let(VaultCrypto::zero)2270 }2271 }2272 }22732274 2275 2276 2277 val rootEscrow = slotA.rootKeyEscrow2278 if (rootEscrow != null) {2279 var candidateRoot: ByteArray? = null2280 var stateCommitStarted = false2281 try {2282 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2283 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2284 val candidateSettings = store.loadSettings()2285 stateCommitStarted = true2286 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2287 clearSessionStateLocked()2288 entryList = mutableListOf()2289 duressConfigured = false2290 recoveryWordHash = recoveryWordsHash(words)2291 recoveryWordsSet = true2292 rootKey = candidateRoot2293 candidateRoot = null2294 _settings.value = candidateSettings2295 publish()2296 sessionDataAvailable = true2297 _isUnlocked.value = false2298 true2299 }2300 if (adopted == true) return@withLock true2301 } catch (_: Exception) {2302 if (stateCommitStarted) {2303 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2304 }2305 } finally {2306 candidateRoot?.let(VaultCrypto::zero)2307 }2308 }2309 return false2310 }23112312 231323142315231623172318231923202321 suspend fun resetPasswordWithRecoveryWords(2322 words: List<String>,2323 newPassword: String,2324 ): Boolean = mutex.withLock {2325 val expectedEpoch = currentEpoch()2326 2327 2328 2329 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2330 val priorA = store.loadSlotA() ?: return false2331 var newKey: ByteArray? = null2332 var plaintext: ByteArray? = null2333 return try {2334 val newSalt = VaultCrypto.generateSalt()2335 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2336 plaintext = VaultCodec.encode(currentPayload())2337 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2338 2339 2340 2341 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2342 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2343 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2344 val next = StoredVault(2345 version = priorA.version,2346 salt = b64(newSalt),2347 blob = blob,2348 recoveryEscrow = freshRecovery,2349 rootKeyEscrow = freshRootKey,2350 )2351 2352 2353 2354 val priorB = store.loadSlotB()2355 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2356 2357 2358 2359 if (mutateIfCurrentEpoch(expectedEpoch) {2360 derivedKey?.let(VaultCrypto::zero)2361 derivedKey = newKey; newKey = null2362 publish()2363 _isUnlocked.value = true2364 } == null) return false2365 true2366 } catch (_: Exception) {2367 false2368 } finally {2369 newKey?.let(VaultCrypto::zero)2370 plaintext?.let(VaultCrypto::zero)2371 }2372 }23732374 237523762377237823792380 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2381 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2382 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2383 var keyBytes: ByteArray? = null2384 var plaintext: ByteArray? = null2385 return try {2386 val salt = VaultCrypto.generateSalt()2387 val recoveryKey = deriveRecoveryKey(material, salt)2388 keyBytes = recoveryKey.encoded2389 plaintext = VaultCodec.encode(currentPayload())2390 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2391 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2392 } finally {2393 VaultCrypto.zero(material)2394 keyBytes?.let(VaultCrypto::zero)2395 plaintext?.let(VaultCrypto::zero)2396 }2397 }23982399 24002401240224032404240524062407 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2408 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2409 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2410 val rk = mutateState { rootKey?.copyOf() } ?: run {2411 VaultCrypto.zero(material)2412 return null2413 }2414 var keyBytes: ByteArray? = null2415 var plaintext: ByteArray? = null2416 return try {2417 val salt = VaultCrypto.generateSalt()2418 val recoveryKey = deriveRecoveryKey(material, salt)2419 keyBytes = recoveryKey.encoded2420 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2421 2422 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2423 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2424 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2425 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2426 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2427 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2428 } finally {2429 VaultCrypto.zero(rk)2430 VaultCrypto.zero(material)2431 keyBytes?.let(VaultCrypto::zero)2432 plaintext?.let(VaultCrypto::zero)2433 }2434 }24352436 2437 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2438 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2439 var keyBytes: ByteArray? = null2440 var pt: ByteArray? = null2441 return try {2442 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2443 keyBytes = recoveryKey.encoded2444 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2445 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2446 hexToRootKey(String(pt, Charsets.US_ASCII))2447 } catch (_: Exception) {2448 null2449 } finally {2450 2451 2452 2453 VaultCrypto.zero(material)2454 keyBytes?.let(VaultCrypto::zero)2455 pt?.let(VaultCrypto::zero)2456 }2457 }24582459 246024612462246324642465 private fun persistSlotAWithEscrows(2466 recoveryEscrow: RecoveryEscrow?,2467 rootKeyEscrow: RootKeyEscrow?,2468 expectedEpoch: Long? = null,2469 ): Boolean {2470 val snapshot = snapshotForPersist(expectedEpoch)2471 ?: throw IllegalStateException("vault_locked_or_stale")2472 if (snapshot.decoy) {2473 VaultCrypto.zero(snapshot.key)2474 return false2475 }2476 try {2477 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2478 val plaintext = VaultCodec.encode(snapshot.payload)2479 val blob = try {2480 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2481 } finally {2482 2483 2484 VaultCrypto.zero(plaintext)2485 }2486 val next = prior.copy(2487 blob = blob,2488 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2489 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2490 )2491 beforeDurableCommit()2492 return withEpochCommit(snapshot.epoch) {2493 if (!store.writeSlotA(next)) return@withEpochCommit false2494 try {2495 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2496 2497 2498 2499 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2500 } catch (e: Exception) {2501 store.writeSlotA(prior)2502 throw e2503 }2504 true2505 } ?: false2506 } finally {2507 VaultCrypto.zero(snapshot.key)2508 }2509 }25102511 25122513 private fun requireUnlocked(): Long {2514 2515 2516 2517 stateLock.lock()2518 try {2519 if (derivedKey == null) throw IllegalStateException("vault_locked")2520 return sessionEpoch2521 } finally {2522 stateLock.unlock()2523 }2524 }25252526 2527 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null25282529 private fun countDuplicateEntriesLocked(): Int {2530 val seen = mutableMapOf<String, VaultEntry>()2531 var duplicates = 02532 for (entry in entryList.filter { it.deletedAt == null }) {2533 val key = entry.duplicateKey()2534 val existing = seen[key]2535 if (existing == null) {2536 seen[key] = entry2537 } else {2538 duplicates++2539 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2540 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2541 if (entryStamp > existingStamp) seen[key] = entry2542 }2543 }2544 return duplicates2545 }25462547 private fun VaultEntry.duplicateKey(): String {2548 if (type == "note") return "note|${title.lowercase()}"2549 val passkeyData = passkey2550 if (passkeyData != null) {2551 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2552 }2553 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2554 }25552556 255725582559256025612562 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2563 val epoch = expectedEpoch ?: currentEpoch()2564 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2565 val slotA = store.loadSlotA() ?: return false2566 val vaultA = VaultCodec.encodeSlot(slotA)2567 val vaultB = store.loadSlotB() ?: slotA.blob2568 2569 2570 2571 2572 2573 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2574 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162575 val fakeBlob = ByteArray(target).also(rng::nextBytes)2576 val fakeSalt = VaultCrypto.generateSalt()2577 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2578 }2579 val history = store.loadVaultHistory()2580 val latest = history.snapshots.firstOrNull()2581 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2582 val entryCount = mutateIfCurrentEpoch(epoch) {2583 if (derivedKey != null && !decoyMode) {2584 bucketEntryCount(entryList.count { it.deletedAt == null })2585 } else {2586 null2587 }2588 } ?: return false2589 return try {2590 val snapshot = VaultSnapshot(2591 id = UUID.randomUUID().toString(),2592 savedAt = System.currentTimeMillis(),2593 reason = reason,2594 vault_a = vaultA,2595 vault_b = vaultB,2596 vault_d = vaultD,2597 entryCount = entryCount,2598 )2599 2600 2601 2602 withEpochCommit(epoch) {2603 store.writeVaultHistory(2604 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2605 )2606 } ?: false2607 } catch (_: Exception) {2608 false2609 }2610 }26112612 private fun bucketEntryCount(n: Int): Int = when {2613 n <= 0 -> 02614 n <= 10 -> 102615 n <= 25 -> 252616 n <= 50 -> 502617 n <= 100 -> 1002618 n <= 250 -> 2502619 n <= 500 -> 5002620 else -> ((n + 499) / 500) * 5002621 }26222623 262426252626262726282629263026312632 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2633 if (!entry.isCard()) return entry2634 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2635 val derived = if (digits.length >= 4) digits.takeLast(4) else null2636 return entry.copy(last4 = derived ?: entry.last4)2637 }26382639 264026412642264326442645 private fun autoPurgeExpiredTrash() {2646 val days = _settings.value.trashRetentionDays ?: return2647 if (days <= 0) return2648 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2649 val before = entryList.size2650 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2651 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2652 }26532654 265526562657265826592660 private fun consolidatePasskeyEntries() {2661 val passkeyOnly = entryList.filter { entry ->2662 entry.deletedAt == null &&2663 entry.isLogin() &&2664 entry.password.isNullOrBlank() &&2665 !entry.rpId.isNullOrBlank() &&2666 !entry.credentialId.isNullOrBlank()2667 }2668 if (passkeyOnly.isEmpty()) return2669 var changed = false2670 val now = System.currentTimeMillis()2671 for (pkEntry in passkeyOnly) {2672 val rpId = pkEntry.rpId ?: continue2673 val target = entryList.firstOrNull { candidate ->2674 candidate.deletedAt == null &&2675 candidate.id != pkEntry.id &&2676 candidate.isLogin() &&2677 candidate.passkey == null &&2678 candidate.credentialId.isNullOrBlank() &&2679 !candidate.password.isNullOrBlank() &&2680 listOfNotNull(candidate.url, *candidate.additionalUrls.orEmpty().toTypedArray())2681 .any { com.peachpasswords.shared.extractDomain(it) == rpId }2682 } ?: continue2683 val idx = entryList.indexOfFirst { it.id == target.id }2684 if (idx < 0) continue2685 entryList[idx] = target.copy(2686 rpId = pkEntry.rpId,2687 rpName = pkEntry.rpName ?: target.rpName,2688 credentialId = pkEntry.credentialId,2689 publicKeyCose = pkEntry.publicKeyCose,2690 privateKeyJwk = pkEntry.privateKeyJwk,2691 userHandle = pkEntry.userHandle,2692 userName = pkEntry.userName,2693 userDisplayName = pkEntry.userDisplayName,2694 algorithm = pkEntry.algorithm,2695 signCount = pkEntry.signCount,2696 lastUsedAt = pkEntry.lastUsedAt,2697 modifiedAt = now,2698 )2699 2700 val pkIdx = entryList.indexOfFirst { it.id == pkEntry.id }2701 if (pkIdx >= 0) entryList[pkIdx] = pkEntry.copy(deletedAt = now)2702 changed = true2703 }2704 if (changed) runCatching { persistSlotA(bumpVersion = false) }2705 }27062707 2708 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }27092710 private fun currentPayloadLocked(): VaultPayload =2711 VaultPayload(2712 entries = entryList.toList(),2713 duressConfigured = duressConfigured,2714 rootKeyB64 = rootKey?.let(::b64) ?: "",2715 recoveryWordHash = recoveryWordHash,2716 recoveryWordsSet = recoveryWordsSet,2717 )27182719 27202721272227232724 private fun ensureRootKey(payload: VaultPayload) {2725 if (payload.rootKeyB64.isNotBlank()) {2726 rootKey = unb64(payload.rootKeyB64)2727 return2728 }2729 rootKey = generateRecoverableRootKey()2730 runCatching { persistSlotA(bumpVersion = false) }2731 }27322733 private fun publish() {2734 _entries.value = entryList.filter { it.deletedAt == null }2735 }27362737 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2738 var plaintext = VaultCodec.encode(currentPayload())2739 return try {2740 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2741 StoredVault(version = 1, salt = b64(salt), blob = blob)2742 } finally {2743 2744 2745 2746 VaultCrypto.zero(plaintext)2747 }2748 }27492750 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2751 2752 2753 2754 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2755 val noise = ByteArray(approxBytes).also(rng::nextBytes)2756 return store.writeSlotB(b64(noise))2757 }27582759 276027612762 private fun matchingCiphertextLength(slotA: StoredVault): Int =2763 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)27642765 27662767276827692770 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2771 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162772 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2773 val fakeSalt = VaultCrypto.generateSalt()2774 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2775 }27762777 277827792780278127822783 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2784 val isConfigured = mutateState { duressConfigured }2785 return if (isConfigured) true else writeFakeDuressSlot(slotA)2786 }27872788 2789279027912792279327942795 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2796 val salt = unhex(b.salt)2797 val nonce = unhex(b.nonce)2798 val ct = unhex(b.ciphertext)2799 val tag = unhex(b.tag)2800 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2801 System.arraycopy(nonce, 0, it, 0, nonce.size)2802 System.arraycopy(ct, 0, it, nonce.size, ct.size)2803 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2804 }2805 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2806 }28072808 2809 private fun persistSlotA(2810 bumpVersion: Boolean,2811 snapshotReason: String? = "local_save",2812 expectedEpoch: Long? = null,2813 ): Boolean {2814 val snapshot = snapshotForPersist(expectedEpoch)2815 ?: throw IllegalStateException("vault_locked_or_stale")2816 return try {2817 2818 if (snapshot.decoy) {2819 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2820 val plaintext = VaultCodec.encode(snapshot.payload)2821 val blob = try {2822 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2823 } finally {2824 2825 2826 VaultCrypto.zero(plaintext)2827 }2828 beforeDurableCommit()2829 return withEpochCommit(snapshot.epoch) {2830 store.writeDecoySlot(prior.copy(blob = blob))2831 } ?: false2832 }2833 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2834 2835 2836 2837 2838 val priorB = store.loadSlotB()2839 beforeDurableCommit()2840 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2841 val plaintext = VaultCodec.encode(snapshot.payload)2842 val blob = try {2843 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2844 } finally {2845 2846 2847 VaultCrypto.zero(plaintext)2848 }2849 val next = prior.copy(blob = blob)2850 2851 2852 2853 2854 withEpochCommit(snapshot.epoch) {2855 if (!store.writeSlotA(next)) return@withEpochCommit false2856 try {2857 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2858 2859 2860 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2861 if (bumpVersion) store.stampLocalMutation()2862 } catch (e: Exception) {2863 store.writeSlotA(prior)2864 if (priorB != null) runCatching { store.writeSlotB(priorB) }2865 throw e2866 }2867 true2868 } ?: false2869 } finally {2870 2871 2872 VaultCrypto.zero(snapshot.key)2873 }2874 }28752876 2877287828792880288128822883 private fun commitVaultRotation(2884 priorA: StoredVault,2885 priorB: String?,2886 nextA: StoredVault,2887 expectedEpoch: Long,2888 ): Boolean {2889 beforeDurableCommit()2890 return withEpochCommit(expectedEpoch) {2891 if (!store.writeSlotA(nextA)) return@withEpochCommit false2892 2893 2894 2895 try {2896 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2897 2898 2899 2900 2901 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2902 store.stampLocalMutation()2903 } catch (_: Exception) {2904 rollbackRotation(priorA, priorB)2905 return@withEpochCommit false2906 }2907 true2908 } ?: false2909 }29102911 29122913291429152916 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2917 val failures = mutableListOf<String>()2918 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2919 .onFailure { failures += "writeSlotA: $it" }2920 if (priorB != null) {2921 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2922 .onFailure { failures += "writeSlotB: $it" }2923 }2924 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2925 }29262927 2928 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2929 try {2930 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2931 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2932 throw IllegalStateException("storage_write_failed")2933 }2934 } catch (e: Exception) {2935 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2936 throw e2937 }2938 2939 2940 2941 mutateIfCurrentEpoch(expectedEpoch) { publish() }2942 runCatching { LockedSyncSpoolHooks.refresh(this) }2943 }2944}