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 kotlinx.serialization.json.JsonElement17import kotlinx.serialization.json.JsonNull18import kotlinx.serialization.json.JsonObject19import kotlinx.serialization.json.JsonPrimitive20import java.util.Base6421import java.util.UUID22import java.util.concurrent.locks.ReentrantLock23import javax.inject.Inject24import javax.inject.Singleton252627private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2829private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)30private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)31private fun unhex(s: String): ByteArray {32 require(s.length % 2 == 0) { "odd-length hex" }33 return ByteArray(s.length / 2) { i ->34 val hi = Character.digit(s[i * 2], 16)35 val lo = Character.digit(s[i * 2 + 1], 16)36 require(hi >= 0 && lo >= 0) { "bad hex digit" }37 ((hi shl 4) or lo).toByte()38 }39}404142private const val PASSWORD_HISTORY_MAX = 5043private const val VAULT_HISTORY_MAX = 544454647484950private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L5152sealed interface UnlockResult {53 data object Success : UnlockResult54 data object WrongPassword : UnlockResult55 data class Error(val reason: String) : UnlockResult56}5758sealed interface CreateVaultResult {59 data object Success : CreateVaultResult60 data class Error(val reason: String) : CreateVaultResult61}6263sealed interface AdoptVaultResult {64 data class Success(val vaultId: String) : AdoptVaultResult65 data class Error(val reason: String) : AdoptVaultResult66}676869707172737475767778798081@Singleton82class VaultRepository @Inject constructor(83 private val store: VaultStorage,84) {85 private val mutex = Mutex()86 87 88 89 90 91 92 93 private val stateLock = ReentrantLock()94 private val rng = SecureRandom()9596 979899100101 private var sessionEpoch: Long = 0L102103 private fun nextSessionEpoch(epoch: Long): Long =104 if (epoch == Long.MAX_VALUE) 0L else epoch + 1L105106 107 @VisibleForTesting108 internal var beforeDurableCommit: () -> Unit = {}109110 111 @VisibleForTesting112 internal var afterCodexRollback: () -> Unit = {}113114 115116117118119 private inline fun <T> mutateState(block: () -> T): T {120 stateLock.lock()121 return try { block() } finally { stateLock.unlock() }122 }123124 125 private inline fun <T> withEpochCommit(epoch: Long, block: () -> T): T? {126 stateLock.lock()127 return try {128 if (sessionEpoch != epoch) null else block()129 } finally {130 stateLock.unlock()131 }132 }133134 135 @VisibleForTesting136 internal var beforeSessionEpochCommit: () -> Unit = {}137138 139140141142143 @VisibleForTesting144 internal var beforeSessionStateMutation: () -> Unit = {}145146 147148149150151152 internal fun <T> withSessionEpochCommit(epoch: Long, block: () -> T): T? {153 beforeSessionEpochCommit()154 return withEpochCommit(epoch, block)155 }156157 private fun currentEpoch(): Long = mutateState { sessionEpoch }158159 private inline fun <T> mutateIfCurrentEpoch(epoch: Long, block: () -> T): T? =160 mutateState { if (sessionEpoch == epoch) block() else null }161162 163 private inline fun <T> mutateCurrentSession(epoch: Long, block: () -> T): T? {164 beforeSessionStateMutation()165 return mutateIfCurrentEpoch(epoch, block)166 }167168 private data class PersistSnapshot(169 val epoch: Long,170 val key: ByteArray,171 val payload: VaultPayload,172 val decoy: Boolean,173 )174175 176 private fun snapshotForPersist(expectedEpoch: Long? = null): PersistSnapshot? = mutateState {177 if (expectedEpoch != null && sessionEpoch != expectedEpoch) return@mutateState null178 val liveKey = derivedKey ?: return@mutateState null179 PersistSnapshot(180 epoch = sessionEpoch,181 key = liveKey.copyOf(),182 payload = currentPayloadLocked(),183 decoy = decoyMode,184 )185 }186187 private var derivedKey: ByteArray? = null188 private var rootKey: ByteArray? = null189 private var entryList: MutableList<VaultEntry> = mutableListOf()190 private var duressConfigured: Boolean = false191 private var recoveryWordHash: String? = null192 private var recoveryWordsSet: Boolean = false193 194 private var sessionDataAvailable: Boolean = false195 196 private var decoyMode: Boolean = false197198 private val _isUnlocked = MutableStateFlow(false)199 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()200201 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())202 203 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()204205 private val _settings = MutableStateFlow(VaultSettings())206 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()207208 209 private fun clearSessionStateLocked() {210 derivedKey?.let(VaultCrypto::zero)211 rootKey?.let(VaultCrypto::zero)212 derivedKey = null213 rootKey = null214 entryList = mutableListOf()215 duressConfigured = false216 recoveryWordHash = null217 recoveryWordsSet = false218 sessionDataAvailable = false219 decoyMode = false220 _settings.value = VaultSettings()221 _entries.value = emptyList()222 _isUnlocked.value = false223 }224225 fun hasVault(): Boolean = store.hasVault()226227 228229 230231232233234235236 fun codexVaultBlobBase64(): String? {237 val slotA = store.loadSlotA() ?: return null238 239 240 val slotD = store.loadDecoySlot() ?: return null241 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)},"b":${VaultCodec.encodeSlot(slotD)}}"""242 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))243 }244245 246247248249250 251 private fun JsonElement?.asSlotJson(): String? = when {252 this == null || this is JsonNull -> null253 this is JsonPrimitive && isString -> content254 else -> toString()255 }256257 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {258 val expectedEpoch = currentEpoch()259 var candidateKey: ByteArray? = null260 var plaintext: ByteArray? = null261 return try {262 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)263 264 265 266 267 268 269 270 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject271 ?: return UnlockResult.Error("invalid_codex_blob")272 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")273 274 275 276 277 278 val slotA = VaultCodec.decodeSlot(slotJson)279 ?: BrowserSlot.parse(slotJson)?.let {280 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword281 }282 ?: return UnlockResult.Error("invalid_codex_blob")283 284 285 286 287 288 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))289 plaintext = decryptSlotABlob(slotA, candidateKey)290 291 292 293 294 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {295 VaultCrypto.zero(candidateKey)296 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))297 plaintext = decryptSlotABlob(slotA, candidateKey)298 }299 if (plaintext == null) return UnlockResult.WrongPassword300 val payload = try {301 VaultCodec.decode(plaintext)302 } catch (_: Exception) {303 return UnlockResult.Error("invalid_codex_blob")304 }305306 val priorA = store.loadSlotA()307 val priorB = store.loadSlotB()308 val priorD = store.loadDecoySlot()309 310 311 312 313 314 val codexDuressJson = wrapper["b"].asSlotJson()315 val codexDuress: StoredVault? = codexDuressJson?.let {316 VaultCodec.decodeSlot(it) ?: BrowserSlot.parse(it)?.let { b ->317 318 319 320 321 322 323 browserDuressPassthrough(b)324 }325 }326327 beforeDurableCommit()328 val committed = runCatching {329 withEpochCommit(expectedEpoch) {330 try {331 332 333 334 335 if (!store.writeSlotA(slotA)) throw java.io.IOException("slot_a_commit_failed")336 337 338 if (!writeMatchingNoiseSlotB(slotA)) throw IllegalStateException("noise_slot_commit_failed")339 340 341 val duressOk = if (codexDuress != null) {342 store.writeDecoySlot(codexDuress)343 } else {344 writeFakeDuressSlot(slotA)345 }346 if (!duressOk) throw IllegalStateException("duress_slot_commit_failed")347 store.stampLocalMutation()348 true349 } catch (_: Exception) {350 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))351 352 353 354 afterCodexRollback()355 false356 }357 } ?: false358 }.getOrDefault(false)359 if (!committed) {360 return UnlockResult.Error("storage_write_failed")361 }362363 val ownedKey = candidateKey!!364 candidateKey = null 365 adoptSlotA(ownedKey, payload, expectedEpoch)366 } catch (e: Exception) {367 368 369 370 371 372 373 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))374 UnlockResult.Error(e.message ?: "restore_failed")375 } finally {376 plaintext?.let(VaultCrypto::zero)377 candidateKey?.let(VaultCrypto::zero)378 }379 }380381 382 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =383 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()384385 386387388389390391392393394 private fun rollbackRestoreLocked(395 priorA: StoredVault?,396 priorB: String?,397 priorD: StoredVault? = null,398 ): List<String> {399 val failures = mutableListOf<String>()400 if (priorA == null) {401 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }402 } else {403 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }404 .onFailure { failures += "writeSlotA: $it" }405 if (priorB != null) {406 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }407 .onFailure { failures += "writeSlotB: $it" }408 }409 410 411 412 if (priorD != null) {413 runCatching { if (!store.writeDecoySlot(priorD)) failures += "writeDecoySlot returned false" }414 .onFailure { failures += "writeDecoySlot: $it" }415 } else {416 runCatching { store.clearDecoySlot() }.onFailure { failures += "clearDecoySlot: $it" }417 }418 }419 return failures420 }421422 private fun reportRollbackFailures(failures: List<String>) {423 424 425 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }426 }427428 429 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {430 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)431 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()432 val window = offset?.let {433 val from = (it - 40).coerceAtLeast(0)434 val to = (it + 40).coerceAtMost(wrapperJson.length)435 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")436 }437 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()438 val slot = wrapper?.get("a")439 buildString {440 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)441 append(" | blobBase64=").append(vaultBlobBase64.length)442 append(" wrapperChars=").append(wrapperJson.length)443 append(" wrapperParses=").append(wrapper != null)444 append(" keys=").append(wrapper?.keys)445 append(" slotAKind=").append(446 when {447 slot == null -> "absent"448 slot is JsonPrimitive && slot.isString -> "string"449 slot is JsonObject -> "object:" + slot.keys450 else -> slot::class.simpleName451 },452 )453 if (window != null) append(" masked@").append(offset).append("=").append(window)454 }455 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }456457 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {458 val expectedEpoch = currentEpoch()459 var key: ByteArray? = null460 return try {461 val salt = VaultCrypto.generateSalt()462 key = VaultCrypto.deriveKey(password, salt)463 if (mutateIfCurrentEpoch(expectedEpoch) {464 entryList = mutableListOf()465 duressConfigured = false466 recoveryWordHash = null467 recoveryWordsSet = false468 decoyMode = false469 sessionDataAvailable = false470 rootKey = generateRecoverableRootKey()471 } == null) return CreateVaultResult.Error("stale_create")472 473 474 475 476 val slotA = sealSlotA(key!!, salt)477 478 479 beforeDurableCommit()480 val created = withEpochCommit(expectedEpoch) {481 if (!store.writeSlotA(slotA)) return@withEpochCommit false482 try {483 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")484 485 486 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")487 store.stampLocalMutation()488 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")489 true490 } catch (_: Exception) {491 492 493 store.clearVault()494 false495 }496 } ?: false497 if (!created) {498 lock()499 return CreateVaultResult.Error("storage_write_failed")500 }501 val sessionKey = key ?: return CreateVaultResult.Error("create_key_missing")502 if (mutateIfCurrentEpoch(expectedEpoch) {503 derivedKey = sessionKey504 key = null505 publish()506 _isUnlocked.value = true507 sessionDataAvailable = true508 sessionEpoch = nextSessionEpoch(sessionEpoch)509 } == null) return CreateVaultResult.Error("stale_create")510 CreateVaultResult.Success511 } catch (e: Exception) {512 lock()513 CreateVaultResult.Error(e.message ?: "create_failed")514 } finally {515 key?.let(VaultCrypto::zero)516 }517 }518519 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {520 val expectedEpoch = currentEpoch()521 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")522 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")523 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {524 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")525 }526 var adoptedRootKey: ByteArray? = null527 var nextDerivedKey: ByteArray? = null528 var sessionRootKey: ByteArray? = null529 return try {530 adoptedRootKey = hexToRootKey(rootKeyHex)531 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {532 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")533 }534 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)535 val salt = VaultCrypto.generateSalt()536 nextDerivedKey = VaultCrypto.deriveKey(password, salt)537 sessionRootKey = adoptedRootKey.copyOf()538 val payload = VaultPayload(539 entries = emptyList(),540 duressConfigured = false,541 rootKeyB64 = b64(sessionRootKey),542 recoveryWordHash = null,543 recoveryWordsSet = false,544 )545 val plaintext = VaultCodec.encode(payload)546 val blob = try {547 b64(VaultCrypto.encrypt(plaintext, nextDerivedKey, VaultCrypto.AAD_SLOT_A))548 } finally {549 VaultCrypto.zero(plaintext)550 }551 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)552 553 554 beforeDurableCommit()555 val created = withEpochCommit(expectedEpoch) {556 if (!store.writeSlotA(slotA)) return@withEpochCommit false557 try {558 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")559 560 561 if (!writeFakeDuressSlot(slotA)) throw java.io.IOException("duress_slot_commit_failed")562 store.stampLocalMutation()563 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")564 true565 } catch (_: Exception) {566 store.clearVault()567 false568 }569 } ?: false570 if (!created) return AdoptVaultResult.Error("storage_write_failed")571572 val adopted = mutateIfCurrentEpoch(expectedEpoch) {573 val previousDerivedKey = derivedKey574 val previousRootKey = rootKey575 derivedKey = nextDerivedKey576 rootKey = sessionRootKey577 nextDerivedKey = null578 sessionRootKey = null579 entryList = mutableListOf()580 duressConfigured = false581 recoveryWordHash = null582 recoveryWordsSet = false583 decoyMode = false584 sessionDataAvailable = true585 publish()586 _isUnlocked.value = true587 sessionEpoch = nextSessionEpoch(sessionEpoch)588 previousDerivedKey to previousRootKey589 }590 if (adopted == null) return AdoptVaultResult.Error("stale_adoption")591 val (previousDerivedKey, previousRootKey) = adopted592 previousDerivedKey?.let(VaultCrypto::zero)593 previousRootKey?.let(VaultCrypto::zero)594 AdoptVaultResult.Success(vaultId)595 } catch (e: Exception) {596 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")597 } finally {598 adoptedRootKey?.let(VaultCrypto::zero)599 nextDerivedKey?.let(VaultCrypto::zero)600 sessionRootKey?.let(VaultCrypto::zero)601 }602 }603604 suspend fun unlock(password: String): UnlockResult = mutex.withLock {605 unlockLocked(password, currentEpoch())606 }607608 609 private fun unlockLocked(password: String, expectedEpoch: Long): UnlockResult {610 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")611 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))612 val payload = try {613 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)614 try {615 VaultCodec.decode(plaintext)616 } finally {617 VaultCrypto.zero(plaintext)618 }619 } catch (_: Exception) {620 VaultCrypto.zero(key)621 622 623 624 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {625 val legacy = tryLegacyRawUnlock(password, slotA, expectedEpoch)626 if (legacy != null) return legacy627 }628 return UnlockResult.WrongPassword629 }630 return adoptSlotA(key, payload, expectedEpoch)631 }632633 634635636637638639640641 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault, expectedEpoch: Long): UnlockResult? {642 var rawKey: ByteArray? = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))643 var newKey: ByteArray? = null644 return try {645 val payload = try {646 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), rawKey!!, VaultCrypto.AAD_SLOT_A)647 try {648 VaultCodec.decode(plaintext)649 } finally {650 VaultCrypto.zero(plaintext)651 }652 } catch (_: Exception) {653 return null654 }655 656 657 val newSalt = VaultCrypto.generateSalt()658 newKey = VaultCrypto.deriveKey(password, newSalt)659 val plaintext = VaultCodec.encode(payload)660 val blob = try {661 b64(VaultCrypto.encrypt(plaintext, newKey!!, VaultCrypto.AAD_SLOT_A))662 } finally {663 VaultCrypto.zero(plaintext)664 }665 val next = StoredVault(666 version = slotA.version,667 salt = b64(newSalt),668 blob = blob,669 recoveryEscrow = slotA.recoveryEscrow,670 rootKeyEscrow = slotA.rootKeyEscrow,671 )672 673 674 675 val priorB = store.loadSlotB()676 beforeDurableCommit()677 val healed = withEpochCommit(expectedEpoch) {678 if (!store.writeSlotA(next)) {679 false680 } else try {681 if (!writeMatchingNoiseSlotB(next)) {682 runCatching { store.writeSlotA(slotA) }683 if (priorB != null) runCatching { store.writeSlotB(priorB) }684 false685 } else if (!writeCompanionDuressSlot(next)) {686 runCatching { store.writeSlotA(slotA) }687 if (priorB != null) runCatching { store.writeSlotB(priorB) }688 false689 } else {690 store.stampLocalMutation()691 true692 }693 } catch (_: Exception) {694 runCatching { store.writeSlotA(slotA) }695 if (priorB != null) runCatching { store.writeSlotB(priorB) }696 false697 }698 } ?: false699 if (healed) {700 VaultCrypto.zero(rawKey!!)701 rawKey = null702 val result = adoptSlotA(newKey!!, payload, expectedEpoch) 703 newKey = null704 result705 } else {706 VaultCrypto.zero(newKey!!)707 newKey = null708 val result = adoptSlotA(rawKey!!, payload, expectedEpoch) 709 rawKey = null710 result711 }712 } finally {713 714 715 rawKey?.let(VaultCrypto::zero)716 newKey?.let(VaultCrypto::zero)717 }718 }719720 721 private fun adoptSlotA(key: ByteArray, payload: VaultPayload, expectedEpoch: Long): UnlockResult {722 return try {723 724 725 726 727 728 val adopted = mutateIfCurrentEpoch(expectedEpoch) {729 derivedKey = key730 decoyMode = false731 entryList = payload.entries.toMutableList()732 duressConfigured = payload.duressConfigured733 recoveryWordHash = payload.recoveryWordHash734 recoveryWordsSet = payload.recoveryWordsSet735 sessionDataAvailable = true736 _settings.value = store.loadSettings()737 ensureRootKey(payload)738 autoPurgeExpiredTrash()739 publish()740 _isUnlocked.value = true741 742 743 744 sessionEpoch = nextSessionEpoch(sessionEpoch)745 }746 if (adopted == null) {747 VaultCrypto.zero(key)748 UnlockResult.Error("stale_unlock")749 } else {750 UnlockResult.Success751 }752 } catch (e: Exception) {753 754 755 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }756 VaultCrypto.zero(key)757 UnlockResult.Error(e.message ?: "unlock_failed")758 }759 }760761 762763 764765766767768769 class SlotAUnlock internal constructor(770 key: ByteArray,771 internal val payload: VaultPayload,772 internal val epoch: Long,773 ) {774 private var ownedKey: ByteArray? = key775 private var consumed = false776777 778 internal fun takeKey(): ByteArray? = synchronized(this) {779 if (consumed) return@synchronized null780 consumed = true781 ownedKey.also { ownedKey = null }782 }783784 785 internal fun discard() = synchronized(this) {786 if (consumed) return@synchronized787 consumed = true788 ownedKey?.let(VaultCrypto::zero)789 ownedKey = null790 }791 }792793 794795796797798799 fun tryUnlockSlotA(password: String): SlotAUnlock? {800 val epoch = currentEpoch()801 val slotA = store.loadSlotA() ?: return null802 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))803 return try {804 val plaintext = VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A)805 val payload = try {806 VaultCodec.decode(plaintext)807 } finally {808 VaultCrypto.zero(plaintext)809 }810 SlotAUnlock(key, payload, epoch)811 } catch (_: Exception) {812 VaultCrypto.zero(key)813 null814 }815 }816817 818 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {819 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")820 adoptSlotA(key, unlock.payload, unlock.epoch)821 }822823 824 fun discardSlotAUnlock(unlock: SlotAUnlock) = unlock.discard()825826 827828 829830831832833 class DuressUnlock internal constructor(834 key: ByteArray,835 internal val payload: VaultPayload,836 internal val epoch: Long,837 ) {838 private var ownedKey: ByteArray? = key839 private var consumed = false840841 internal fun takeKey(): ByteArray? = synchronized(this) {842 if (consumed) return@synchronized null843 consumed = true844 ownedKey.also { ownedKey = null }845 }846847 internal fun discard() = synchronized(this) {848 if (consumed) return@synchronized849 consumed = true850 ownedKey?.let(VaultCrypto::zero)851 ownedKey = null852 }853 }854855 856857858859860861862 fun tryUnlockDuress(password: String): DuressUnlock? {863 val epoch = currentEpoch()864 val slot = store.loadDecoySlot() ?: return null865 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))866 return try {867 val plaintext = VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A)868 val payload = try {869 VaultCodec.decode(plaintext)870 } finally {871 VaultCrypto.zero(plaintext)872 }873 DuressUnlock(key, payload, epoch)874 } catch (_: Exception) {875 VaultCrypto.zero(key)876 null877 }878 }879880 881 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {882 val key = unlock.takeKey() ?: return@withLock UnlockResult.Error("unlock_token_consumed")883 if (currentEpoch() != unlock.epoch) {884 VaultCrypto.zero(key)885 return@withLock UnlockResult.Error("stale_unlock")886 }887 return try {888 val adopted = mutateIfCurrentEpoch(unlock.epoch) {889 derivedKey = key890 decoyMode = true891 entryList = unlock.payload.entries.toMutableList()892 duressConfigured = false893 recoveryWordHash = unlock.payload.recoveryWordHash894 recoveryWordsSet = unlock.payload.recoveryWordsSet895 sessionDataAvailable = true896 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)897 else VaultCrypto.generateRootKey()898 _settings.value = store.loadSettings()899 publish()900 _isUnlocked.value = true901 sessionEpoch = nextSessionEpoch(sessionEpoch)902 }903 if (adopted == null) {904 VaultCrypto.zero(key)905 UnlockResult.Error("stale_unlock")906 } else {907 UnlockResult.Success908 }909 } catch (e: Exception) {910 911 912 913 914 mutateIfCurrentEpoch(unlock.epoch) { clearSessionStateLocked() }915 VaultCrypto.zero(key)916 UnlockResult.Error(e.message ?: "decoy_unlock_failed")917 }918 }919920 921 fun discardDuressUnlock(unlock: DuressUnlock) = unlock.discard()922923 924925926927928929 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {930 931 val expectedEpoch = mutateState {932 if (derivedKey != null && !decoyMode) sessionEpoch else null933 } ?: return false934 935 936 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }937 var key: ByteArray? = null938 var basePlaintext: ByteArray? = null939 return try {940 val salt = VaultCrypto.generateSalt()941 key = VaultCrypto.deriveKey(duressPassphrase, salt)942 943 val decoyRoot = VaultCrypto.generateRootKey()944 val decoyRootB64 = try { b64(decoyRoot) } finally { VaultCrypto.zero(decoyRoot) }945 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = decoyRootB64)946 basePlaintext = VaultCodec.encode(decoyPayload)947 948 949 950 951 952 953 954 955 val slotA = store.loadSlotA()956 val targetSize = slotA?.let(::matchingCiphertextLength) ?: 0957 val plaintext = if (targetSize > basePlaintext!!.size) basePlaintext!!.copyOf(targetSize) else basePlaintext!!958 val blob = try {959 b64(VaultCrypto.encrypt(plaintext, key!!, VaultCrypto.AAD_SLOT_A))960 } finally {961 VaultCrypto.zero(plaintext)962 963 964 if (plaintext !== basePlaintext) basePlaintext?.let(VaultCrypto::zero)965 basePlaintext = null966 key?.let(VaultCrypto::zero)967 key = null968 }969 beforeDurableCommit()970 val ok = withEpochCommit(expectedEpoch) {971 store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))972 } ?: false973 if (ok && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = true } != null) {974 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }975 } else if (ok) {976 return false977 }978 ok979 } catch (_: Exception) {980 false981 } finally {982 983 984 key?.let(VaultCrypto::zero)985 basePlaintext?.let(VaultCrypto::zero)986 }987 }988989 990 suspend fun clearDuress(): Boolean = mutex.withLock {991 992 993 994 995 val expectedEpoch = currentEpoch()996 val replaced = withEpochCommit(expectedEpoch) {997 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }998 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)999 } ?: false1000 if (!replaced) return@withLock false1001 1002 1003 if (!isSessionEpochCurrent(expectedEpoch)) return false1004 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1005 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1006 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1007 }1008 true1009 }10101011 1012 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1013 var current: Throwable? = error1014 while (current != null) {1015 if (current is javax.crypto.AEADBadTagException) return true1016 1017 1018 1019 1020 if (current is javax.crypto.BadPaddingException) return true1021 current = current.cause1022 }1023 return false1024 }10251026 1027 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1028 val expectedEpoch = currentEpoch()1029 val slotA = try {1030 store.loadSlotA()1031 } catch (_: Exception) {1032 return@withLock UnlockResult.Error("unlock_key_storage_failed")1033 } ?: return@withLock UnlockResult.Error("no_vault")1034 1035 1036 1037 1038 var stagedKey: ByteArray? = key.copyOf()1039 return try {1040 val encrypted = try {1041 unb64(slotA.blob)1042 } catch (_: Exception) {1043 return@withLock UnlockResult.Error("invalid_vault_blob")1044 }1045 1046 1047 1048 1049 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1050 return@withLock UnlockResult.Error("invalid_vault_blob")1051 }1052 val plaintext = try {1053 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1054 } catch (e: Exception) {1055 if (isGcmAuthenticationFailure(e)) {1056 return@withLock UnlockResult.WrongPassword1057 }1058 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1059 }1060 val payload = try {1061 VaultCodec.decode(plaintext)1062 } catch (_: Exception) {1063 return@withLock UnlockResult.Error("invalid_vault_payload")1064 } finally {1065 VaultCrypto.zero(plaintext)1066 }1067 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1068 val result = adoptSlotA(candidate, payload, expectedEpoch)1069 if (result is UnlockResult.Success) stagedKey = null1070 result1071 } finally {1072 stagedKey?.let(VaultCrypto::zero)1073 }1074 }10751076 fun lock() {1077 1078 1079 1080 1081 1082 1083 stateLock.lock()1084 try {1085 sessionEpoch = nextSessionEpoch(sessionEpoch)1086 clearSessionStateLocked()1087 } finally {1088 stateLock.unlock()1089 }1090 }10911092 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L10931094 1095 1096 @VisibleForTesting1097 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }10981099 110011011102110311041105110611071108110911101111 fun expectTransientBackground() {1112 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1113 }11141115 11161117111811191120 fun consumeTransientBackgroundSuppression(): Boolean {1121 val until = suppressNextBackgroundLockUntil1122 val active = until != 0L && nowMs() < until1123 suppressNextBackgroundLockUntil = 0L1124 return active1125 }11261127 11281129 11301131113211331134 11351136113711381139114011411142114311441145 1146 1147 1148 1149 1150 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11511152 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1153 if (decoyMode || !_isUnlocked.value) null1154 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1155 }11561157 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1158 mutateState { sessionEpoch == epoch }11591160 internal fun unlockedSessionEpoch(): Long? = mutateState {1161 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1162 }11631164 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key11651166 116711681169117011711172117311741175 fun sessionRootKey(): ByteArray? = mutateState {1176 if (!sessionDataAvailable) null else rootKey?.copyOf()1177 }11781179 11801181 fun syncVaultId(): String? = mutateState {1182 if (decoyMode || !_isUnlocked.value) null1183 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1184 }11851186 11871188118911901191 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1192 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1193 ?: return false1194 installRootKey(generateRecoverableRootKey(), expectedEpoch)1195 }11961197 11981199120012011202120312041205120612071208120912101211121212131214 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1215 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1216 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1217 }1218 val expectedEpoch = mutateState {1219 when {1220 derivedKey == null -> null1221 decoyMode -> Long.MIN_VALUE1222 else -> sessionEpoch1223 }1224 }1225 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1226 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1227 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12281229 val incoming = hexToRootKey(rootKeyHex)1230 if (incoming.size != VaultCrypto.KEY_SIZE) {1231 VaultCrypto.zero(incoming)1232 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1233 }1234 1235 1236 1237 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1238 if (!roundTrips) {1239 VaultCrypto.zero(incoming)1240 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1241 }12421243 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1244 return if (installRootKey(incoming, expectedEpoch)) {1245 AdoptVaultResult.Success(vaultId)1246 } else {1247 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1248 }1249 }12501251 12521253125412551256 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1257 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1258 val bytes = hexToRootKey(rootKeyHex)1259 return try {1260 val words = rootKeyToWords(bytes)1261 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1262 } catch (_: Exception) {1263 null1264 } finally {1265 VaultCrypto.zero(bytes)1266 }1267 }12681269 12701271127212731274 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1275 1276 1277 1278 1279 1280 1281 data class Snapshot(1282 val key: ByteArray,1283 val priorRootKey: ByteArray,1284 val priorRecoveryHash: String?,1285 val priorRecoverySet: Boolean,1286 )1287 val snap = mutateIfCurrentEpoch(expectedEpoch) {1288 val k = derivedKey ?: return@mutateIfCurrentEpoch null1289 if (decoyMode) return@mutateIfCurrentEpoch null1290 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1291 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1292 } ?: run {1293 VaultCrypto.zero(nextRootKey)1294 return false1295 }1296 val priorSlot = store.loadSlotA() ?: run {1297 VaultCrypto.zero(snap.key)1298 VaultCrypto.zero(snap.priorRootKey)1299 VaultCrypto.zero(nextRootKey)1300 return false1301 }1302 val nextWords = rootKeyToWords(nextRootKey)13031304 return try {1305 beforeDurableCommit()1306 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1307 if (mutateIfCurrentEpoch(expectedEpoch) {1308 rootKey = nextRootKey1309 recoveryWordHash = recoveryWordsHash(nextWords)1310 recoveryWordsSet = false1311 } == null) throw IllegalStateException("stale_root_rotation")13121313 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1314 ?: throw IllegalStateException("recovery_escrow_failed")1315 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1316 ?: throw IllegalStateException("root_escrow_failed")1317 val plaintext = VaultCodec.encode(currentPayload())1318 val blob = try {1319 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1320 } finally {1321 VaultCrypto.zero(plaintext)1322 }1323 val nextSlot = priorSlot.copy(1324 blob = blob,1325 recoveryEscrow = nextRecoveryEscrow,1326 rootKeyEscrow = nextRootKeyEscrow,1327 )1328 val committed = withEpochCommit(expectedEpoch) {1329 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1330 try {1331 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1332 1333 1334 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1335 store.stampLocalMutation()1336 } catch (_: Exception) {1337 store.writeSlotA(priorSlot)1338 return@withEpochCommit false1339 }1340 true1341 } ?: false1342 if (!committed) throw IllegalStateException("storage_write_failed")1343 mutateIfCurrentEpoch(expectedEpoch) { publish() }1344 true1345 } catch (_: Exception) {1346 val restored = mutateIfCurrentEpoch(expectedEpoch) {1347 rootKey?.let(VaultCrypto::zero)1348 rootKey = snap.priorRootKey.copyOf()1349 recoveryWordHash = snap.priorRecoveryHash1350 recoveryWordsSet = snap.priorRecoverySet1351 }1352 if (restored != null) {1353 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1354 }1355 false1356 } finally {1357 VaultCrypto.zero(snap.key)1358 VaultCrypto.zero(snap.priorRootKey)1359 1360 1361 1362 val stillOwnedBySession = mutateState {1363 sessionEpoch == expectedEpoch && rootKey === nextRootKey1364 }1365 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1366 }1367 }13681369 13701371137213731374 fun currentVaultKey(): ByteArray? = mutateState {1375 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1376 }13771378 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1379 val expectedEpoch = currentEpoch()1380 val slotA = store.loadSlotA() ?: return false1381 var oldKey: ByteArray? = null1382 var newKey: ByteArray? = null1383 var plaintext: ByteArray? = null1384 return try {1385 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1386 1387 1388 1389 1390 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1391 val payload = try {1392 VaultCodec.decode(decrypted)1393 } finally {1394 VaultCrypto.zero(decrypted)1395 }1396 val newSalt = VaultCrypto.generateSalt()1397 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1398 1399 plaintext = VaultCodec.encode(payload)1400 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1401 1402 1403 1404 1405 1406 1407 1408 val next = StoredVault(1409 version = slotA.version,1410 salt = b64(newSalt),1411 blob = blob,1412 recoveryEscrow = slotA.recoveryEscrow,1413 rootKeyEscrow = slotA.rootKeyEscrow,1414 )1415 1416 1417 1418 1419 val priorB = store.loadSlotB()1420 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1421 1422 1423 1424 if (mutateIfCurrentEpoch(expectedEpoch) {1425 oldKey?.let(VaultCrypto::zero); oldKey = null1426 derivedKey?.let(VaultCrypto::zero)1427 derivedKey = newKey; newKey = null1428 } == null) return false1429 true1430 } catch (_: Exception) {1431 false1432 } finally {1433 oldKey?.let(VaultCrypto::zero)1434 newKey?.let(VaultCrypto::zero)1435 plaintext?.let(VaultCrypto::zero)1436 }1437 }14381439 1440144114421443 suspend fun destroyVault() = mutex.withLock {1444 lock()1445 store.clearVault()1446 store.clearDecoySlot()1447 }14481449 14501451 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1452 val epoch = requireUnlocked()1453 val stamp = VaultCrypto.monotonicEntryTimestamp()1454 val now = System.currentTimeMillis()1455 val entry = withDerivedLast4(draft.copy(1456 id = draft.id.ifBlank { UUID.randomUUID().toString() },1457 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1458 modifiedAt = stamp,1459 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1460 ))1461 1462 1463 val rollback = mutateCurrentSession(epoch) {1464 val r = entryList.toMutableList()1465 entryList.add(entry)1466 r1467 } ?: throw IllegalStateException("vault_locked")1468 persistOrRollback(rollback, epoch)1469 entry1470 }14711472 147314741475147614771478147914801481 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1482 val epoch = requireUnlocked()1483 if (drafts.isEmpty()) return 01484 val now = System.currentTimeMillis()1485 val ready = drafts.map { draft ->1486 val stamp = VaultCrypto.monotonicEntryTimestamp()1487 withDerivedLast4(1488 draft.copy(1489 id = draft.id.ifBlank { UUID.randomUUID().toString() },1490 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1491 modifiedAt = stamp,1492 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1493 ),1494 )1495 }1496 1497 val rollback = mutateCurrentSession(epoch) {1498 val r = entryList.toMutableList()1499 entryList.addAll(ready)1500 r1501 } ?: throw IllegalStateException("vault_locked")1502 persistOrRollback(rollback, epoch)1503 return drafts.size1504 }15051506 1507150815091510151115121513151415151516151715181519 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1520 val epoch = requireUnlocked()1521 val now = System.currentTimeMillis()1522 val ready = newEntries.map { draft ->1523 val stamp = VaultCrypto.monotonicEntryTimestamp()1524 withDerivedLast4(1525 draft.copy(1526 id = draft.id.ifBlank { UUID.randomUUID().toString() },1527 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1528 modifiedAt = stamp,1529 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1530 ),1531 )1532 }1533 1534 1535 1536 1537 1538 1539 val incomingIds = ready.map { it.id }.toHashSet()1540 val rollback = mutateCurrentSession(epoch) {1541 val r = entryList.toMutableList()1542 val replaced = entryList.map { entry ->1543 if (entry.deletedAt != null) return@map entry1544 if (entry.id in incomingIds) return@map null1545 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1546 entry.copy(deletedAt = stamp, modifiedAt = stamp)1547 }.filterNotNull()1548 entryList = (replaced + ready).toMutableList()1549 r1550 } ?: throw IllegalStateException("vault_locked")1551 persistOrRollback(rollback, epoch)1552 return ready.size1553 }15541555 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1556 val epoch = requireUnlocked()1557 1558 1559 1560 1561 val prepared = mutateCurrentSession(epoch) {1562 val idx = entryList.indexOfFirst { it.id == patch.id }1563 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1564 val current = entryList[idx]1565 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1566 1567 val passwordChanged = patch.password != null && patch.password != current.password1568 1569 1570 1571 1572 1573 1574 val priorPassword = current.password1575 val shouldCapture = passwordChanged &&1576 !priorPassword.isNullOrEmpty() &&1577 current.passwordHistory?.firstOrNull()?.password != priorPassword1578 val nextHistory = if (shouldCapture) {1579 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1580 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1581 } else {1582 current.passwordHistory1583 }1584 val updated = withDerivedLast4(patch.copy(1585 createdAt = current.createdAt,1586 modifiedAt = stamp,1587 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1588 passwordHistory = nextHistory,1589 ))1590 val rollback = entryList.toMutableList()1591 entryList[idx] = updated1592 updated to rollback1593 } ?: throw IllegalStateException("vault_locked")1594 val (updated, rollback) = prepared1595 persistOrRollback(rollback, epoch)1596 updated1597 }15981599 1600 suspend fun deleteEntry(id: String) = mutex.withLock {1601 val epoch = requireUnlocked()1602 1603 1604 val rollback = mutateCurrentSession(epoch) {1605 val idx = entryList.indexOfFirst { it.id == id }1606 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1607 val current = entryList[idx]1608 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1609 val prior = entryList.toMutableList()1610 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1611 prior1612 } ?: throw IllegalStateException("vault_locked")1613 persistOrRollback(rollback, epoch)1614 }16151616 suspend fun restoreEntry(id: String) = mutex.withLock {1617 val epoch = requireUnlocked()1618 val rollback = mutateCurrentSession(epoch) {1619 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1620 if (idx == -1) throw IllegalStateException("deleted entry not found")1621 val current = entryList[idx]1622 val prior = entryList.toMutableList()1623 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1624 prior1625 } ?: throw IllegalStateException("vault_locked")1626 persistOrRollback(rollback, epoch)1627 }16281629 1630 suspend fun purgeEntry(id: String) = mutex.withLock {1631 val epoch = requireUnlocked()1632 val rollback = mutateCurrentSession(epoch) {1633 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1634 if (idx == -1) throw IllegalStateException("deleted entry not found")1635 val prior = entryList.toMutableList()1636 entryList.removeAt(idx)1637 prior1638 } ?: throw IllegalStateException("vault_locked")1639 persistOrRollback(rollback, epoch)1640 }16411642 1643 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1644 val epoch = requireUnlocked()1645 1646 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1647 val r = mutateCurrentSession(epoch) {1648 val rollback = entryList.toMutableList()1649 val before = entryList.size1650 entryList.removeAll { it.deletedAt != null }1651 R(rollback, before - entryList.size)1652 } ?: throw IllegalStateException("vault_locked")1653 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1654 r.removed1655 }16561657 1658 suspend fun countDuplicateEntries(): Int = mutex.withLock {1659 val epoch = requireUnlocked()1660 1661 1662 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1663 ?: throw IllegalStateException("vault_locked")1664 }16651666 16671668166916701671 suspend fun deduplicateEntries(): Int = mutex.withLock {1672 val epoch = requireUnlocked()1673 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1674 val ready = mutateCurrentSession(epoch) {1675 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1676 val active = entryList.filter { it.deletedAt == null }1677 val keepIds = mutableSetOf<String>()1678 active.groupBy { it.duplicateKey() }.values.forEach { group ->1679 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1680 }1681 val rollback = entryList.toMutableList()1682 var removed = 01683 entryList = entryList.map { entry ->1684 if (entry.deletedAt == null && entry.id !in keepIds) {1685 removed++1686 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1687 entry.copy(deletedAt = stamp, modifiedAt = stamp)1688 } else {1689 entry1690 }1691 }.toMutableList()1692 R(rollback, removed)1693 } ?: throw IllegalStateException("vault_locked")1694 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1695 ready.removed1696 }16971698 fun getEntries(): List<VaultEntry> = mutateState {1699 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1700 }1701 fun getEntry(id: String): VaultEntry? = mutateState {1702 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1703 }1704 fun getDeletedEntries(): List<VaultEntry> = mutateState {1705 if (!sessionDataAvailable) emptyList()1706 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1707 }17081709 1710 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1711 return mutateState {1712 if (!sessionDataAvailable) return@mutateState emptyList()1713 val active = entryList.filter { it.deletedAt == null }1714 if (tagFilter == null) return@mutateState active1715 val needle = tagFilter.trim().lowercase()1716 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1717 }1718 }17191720 1721 fun getDistinctTags(): List<String> = mutateState {1722 if (!sessionDataAvailable) return@mutateState emptyList()1723 entryList.asSequence()1724 .filter { it.deletedAt == null }1725 .flatMap { it.tags?.asSequence() ?: emptySequence() }1726 .map { it.trim().lowercase() }1727 .filter { it.isNotEmpty() }1728 .distinct()1729 .sorted()1730 .toList()1731 }17321733 173417351736173717381739 fun getDistinctFieldValues(field: String): List<String> {1740 val select: (VaultEntry) -> String? = when (field) {1741 "username" -> { e -> e.username }1742 "email" -> { e -> e.email }1743 else -> return emptyList()1744 }1745 return mutateState {1746 if (!sessionDataAvailable) return@mutateState emptyList()1747 entryList.asSequence()1748 .filter { it.deletedAt == null }1749 .mapNotNull { select(it)?.trim() }1750 .filter { it.isNotEmpty() }1751 .groupingBy { it.lowercase() }1752 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1753 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1754 }1755 .values1756 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1757 .map { it.first }1758 }1759 }17601761 fun getTombstones(): List<Tombstone> = mutateState {1762 if (!sessionDataAvailable) emptyList()1763 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1764 }1765 fun getVaultVersion(): Long = store.version()1766 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots17671768 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1769 val expectedEpoch = currentEpoch()1770 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1771 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1772 1773 1774 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1775 val priorA = store.loadSlotA()1776 val priorB = store.loadSlotB()1777 val priorD = store.loadDecoySlot()1778 1779 1780 1781 1782 1783 val priorHistory = store.loadVaultHistory()1784 1785 1786 1787 1788 beforeDurableCommit()1789 if (!takeVaultSnapshot("restore", expectedEpoch)) {1790 return false1791 }1792 1793 1794 1795 val nextD: StoredVault = restoredD ?: run {1796 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161797 val fakeBlob = ByteArray(target).also(rng::nextBytes)1798 val fakeSalt = VaultCrypto.generateSalt()1799 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1800 }1801 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1802 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1803 return false1804 }1805 lock()1806 true1807 }18081809 18101811181218131814181518161817181818191820 private fun commitRestoreAtomic(1821 nextA: StoredVault,1822 nextB: String,1823 nextD: StoredVault,1824 priorA: StoredVault?,1825 priorB: String?,1826 priorD: StoredVault?,1827 expectedEpoch: Long,1828 ): Boolean {1829 return withEpochCommit(expectedEpoch) {1830 if (!store.writeSlotA(nextA)) return@withEpochCommit false1831 1832 1833 1834 try {1835 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1836 1837 1838 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1839 store.stampLocalMutation()1840 } catch (_: Exception) {1841 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1842 return@withEpochCommit false1843 }1844 true1845 } ?: false1846 }18471848 18491850 18511852185318541855 suspend fun applyMergedRemoteEntries(1856 remoteEntries: List<VaultEntry>,1857 remoteTombstones: List<Tombstone>,1858 expectedEpoch: Long? = null,1859 ): Int = mutex.withLock {1860 val epoch = expectedEpoch ?: requireUnlocked()1861 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01862 1863 1864 1865 1866 1867 val mergeNow = System.currentTimeMillis()1868 val clamped = remoteEntries.map { entry ->1869 entry.copy(1870 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1871 deletedAt = entry.deletedAt?.let {1872 SyncLimits.clampSyncTimestamp(it, mergeNow)1873 },1874 )1875 }1876 val tombMap = remoteTombstones.associate {1877 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1878 }1879 1880 1881 val applied = mutateCurrentSession(epoch) {1882 var n = 01883 val byId = entryList.associateBy { it.id }.toMutableMap()18841885 for (remote in clamped) {1886 val tomb = tombMap[remote.id]1887 if (tomb != null && tomb >= remote.modifiedAt) continue1888 val local = byId[remote.id]1889 if (local == null) {1890 entryList.add(remote); byId[remote.id] = remote; n++1891 } else if (remote.modifiedAt > local.modifiedAt) {1892 val i = entryList.indexOfFirst { it.id == local.id }1893 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1894 }1895 }18961897 for ((id, deletedAt) in tombMap) {1898 val local = byId[id] ?: continue1899 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1900 if (deletedAt < local.modifiedAt) continue1901 val i = entryList.indexOfFirst { it.id == id }1902 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1903 }1904 n1905 } ?: return@withLock 019061907 if (applied > 0) {1908 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1909 mutateIfCurrentEpoch(epoch) { publish() }1910 }1911 applied1912 }19131914 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1915 1916 1917 1918 val epoch = requireUnlocked()1919 val prior = mutateCurrentSession(epoch) {1920 val old = _settings.value1921 _settings.value = settings1922 old1923 } ?: throw IllegalStateException("vault_locked")19241925 val committed = try {1926 beforeDurableCommit()1927 1928 1929 1930 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1931 } catch (_: Exception) {1932 false1933 }1934 if (!committed) {1935 1936 1937 1938 mutateIfCurrentEpoch(epoch) { _settings.value = prior }1939 throw IllegalStateException("settings_write_failed")1940 }1941 }19421943 19441945 1946 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19471948 19491950195119521953 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1954 val epoch = requireUnlocked()1955 1956 1957 1958 1959 1960 1961 val prior = mutateCurrentSession(epoch) {1962 val priorPair = recoveryWordHash to recoveryWordsSet1963 recoveryWordHash = recoveryWordsHash(words)1964 recoveryWordsSet = true1965 priorPair1966 } ?: return@withLock false1967 1968 1969 1970 1971 val persisted = runCatching {1972 persistSlotAWithEscrows(1973 buildRecoveryEscrow(words),1974 buildRootKeyEscrow(words),1975 expectedEpoch = epoch,1976 )1977 }.getOrDefault(false)1978 if (!persisted) {1979 mutateIfCurrentEpoch(epoch) {1980 recoveryWordHash = prior.first1981 recoveryWordsSet = prior.second1982 }1983 }1984 persisted1985 }19861987 1988 fun verifyRecoveryWords(words: List<String>): Boolean {1989 return mutateState {1990 val stored = recoveryWordHash ?: return@mutateState false1991 stored == recoveryWordsHash(words)1992 }1993 }19941995 19961997 1998 fun hasRecoveryEscrow(): Boolean =1999 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20002001 200220032004200520062007200820092010201120122013 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2014 val expectedEpoch = currentEpoch()2015 val slotA = store.loadSlotA() ?: return false2016 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20172018 2019 2020 val fullEscrow = slotA.recoveryEscrow2021 if (fullEscrow != null) {2022 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2023 if (material != null) {2024 var keyBytes: ByteArray? = null2025 var plaintext: ByteArray? = null2026 var candidateRoot: ByteArray? = null2027 var stateCommitStarted = false2028 try {2029 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2030 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2031 val payload = VaultCodec.decode(plaintext)2032 2033 2034 2035 2036 val candidateSettings = store.loadSettings()2037 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2038 val decodedRoot = unb64(payload.rootKeyB64)2039 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2040 2041 2042 2043 VaultCrypto.zero(decodedRoot)2044 throw IllegalArgumentException("invalid recovery root key")2045 }2046 decodedRoot2047 } else {2048 generateRecoverableRootKey()2049 }2050 stateCommitStarted = true2051 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2052 clearSessionStateLocked()2053 entryList = payload.entries.toMutableList()2054 duressConfigured = payload.duressConfigured2055 recoveryWordHash = payload.recoveryWordHash2056 recoveryWordsSet = payload.recoveryWordsSet2057 rootKey = candidateRoot2058 candidateRoot = null2059 _settings.value = candidateSettings2060 publish()2061 2062 2063 sessionDataAvailable = true2064 _isUnlocked.value = false2065 true2066 }2067 if (adopted == true) return@withLock true2068 } catch (_: Exception) {2069 2070 2071 2072 2073 if (stateCommitStarted) {2074 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2075 }2076 } finally {2077 plaintext?.let(VaultCrypto::zero)2078 VaultCrypto.zero(material)2079 keyBytes?.let(VaultCrypto::zero)2080 candidateRoot?.let(VaultCrypto::zero)2081 }2082 }2083 }20842085 2086 2087 2088 val rootEscrow = slotA.rootKeyEscrow2089 if (rootEscrow != null) {2090 var candidateRoot: ByteArray? = null2091 var stateCommitStarted = false2092 try {2093 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2094 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2095 val candidateSettings = store.loadSettings()2096 stateCommitStarted = true2097 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2098 clearSessionStateLocked()2099 entryList = mutableListOf()2100 duressConfigured = false2101 recoveryWordHash = recoveryWordsHash(words)2102 recoveryWordsSet = true2103 rootKey = candidateRoot2104 candidateRoot = null2105 _settings.value = candidateSettings2106 publish()2107 sessionDataAvailable = true2108 _isUnlocked.value = false2109 true2110 }2111 if (adopted == true) return@withLock true2112 } catch (_: Exception) {2113 if (stateCommitStarted) {2114 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2115 }2116 } finally {2117 candidateRoot?.let(VaultCrypto::zero)2118 }2119 }2120 return false2121 }21222123 212421252126212721282129213021312132 suspend fun resetPasswordWithRecoveryWords(2133 words: List<String>,2134 newPassword: String,2135 ): Boolean = mutex.withLock {2136 val expectedEpoch = currentEpoch()2137 2138 2139 2140 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2141 val priorA = store.loadSlotA() ?: return false2142 var newKey: ByteArray? = null2143 var plaintext: ByteArray? = null2144 return try {2145 val newSalt = VaultCrypto.generateSalt()2146 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2147 plaintext = VaultCodec.encode(currentPayload())2148 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2149 2150 2151 2152 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2153 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2154 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2155 val next = StoredVault(2156 version = priorA.version,2157 salt = b64(newSalt),2158 blob = blob,2159 recoveryEscrow = freshRecovery,2160 rootKeyEscrow = freshRootKey,2161 )2162 2163 2164 2165 val priorB = store.loadSlotB()2166 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2167 2168 2169 2170 if (mutateIfCurrentEpoch(expectedEpoch) {2171 derivedKey?.let(VaultCrypto::zero)2172 derivedKey = newKey; newKey = null2173 publish()2174 _isUnlocked.value = true2175 } == null) return false2176 true2177 } catch (_: Exception) {2178 false2179 } finally {2180 newKey?.let(VaultCrypto::zero)2181 plaintext?.let(VaultCrypto::zero)2182 }2183 }21842185 218621872188218921902191 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2192 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2193 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2194 var keyBytes: ByteArray? = null2195 var plaintext: ByteArray? = null2196 return try {2197 val salt = VaultCrypto.generateSalt()2198 val recoveryKey = deriveRecoveryKey(material, salt)2199 keyBytes = recoveryKey.encoded2200 plaintext = VaultCodec.encode(currentPayload())2201 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2202 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2203 } finally {2204 VaultCrypto.zero(material)2205 keyBytes?.let(VaultCrypto::zero)2206 plaintext?.let(VaultCrypto::zero)2207 }2208 }22092210 22112212221322142215221622172218 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2219 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2220 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2221 val rk = mutateState { rootKey?.copyOf() } ?: run {2222 VaultCrypto.zero(material)2223 return null2224 }2225 var keyBytes: ByteArray? = null2226 var plaintext: ByteArray? = null2227 return try {2228 val salt = VaultCrypto.generateSalt()2229 val recoveryKey = deriveRecoveryKey(material, salt)2230 keyBytes = recoveryKey.encoded2231 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2232 2233 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2234 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2235 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2236 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2237 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2238 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2239 } finally {2240 VaultCrypto.zero(rk)2241 VaultCrypto.zero(material)2242 keyBytes?.let(VaultCrypto::zero)2243 plaintext?.let(VaultCrypto::zero)2244 }2245 }22462247 2248 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2249 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2250 var keyBytes: ByteArray? = null2251 var pt: ByteArray? = null2252 return try {2253 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2254 keyBytes = recoveryKey.encoded2255 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2256 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2257 hexToRootKey(String(pt, Charsets.US_ASCII))2258 } catch (_: Exception) {2259 null2260 } finally {2261 2262 2263 2264 VaultCrypto.zero(material)2265 keyBytes?.let(VaultCrypto::zero)2266 pt?.let(VaultCrypto::zero)2267 }2268 }22692270 227122722273227422752276 private fun persistSlotAWithEscrows(2277 recoveryEscrow: RecoveryEscrow?,2278 rootKeyEscrow: RootKeyEscrow?,2279 expectedEpoch: Long? = null,2280 ): Boolean {2281 val snapshot = snapshotForPersist(expectedEpoch)2282 ?: throw IllegalStateException("vault_locked_or_stale")2283 if (snapshot.decoy) {2284 VaultCrypto.zero(snapshot.key)2285 return false2286 }2287 try {2288 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2289 val plaintext = VaultCodec.encode(snapshot.payload)2290 val blob = try {2291 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2292 } finally {2293 2294 2295 VaultCrypto.zero(plaintext)2296 }2297 val next = prior.copy(2298 blob = blob,2299 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2300 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2301 )2302 beforeDurableCommit()2303 return withEpochCommit(snapshot.epoch) {2304 if (!store.writeSlotA(next)) return@withEpochCommit false2305 try {2306 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2307 2308 2309 2310 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2311 } catch (e: Exception) {2312 store.writeSlotA(prior)2313 throw e2314 }2315 true2316 } ?: false2317 } finally {2318 VaultCrypto.zero(snapshot.key)2319 }2320 }23212322 23232324 private fun requireUnlocked(): Long {2325 2326 2327 2328 stateLock.lock()2329 try {2330 if (derivedKey == null) throw IllegalStateException("vault_locked")2331 return sessionEpoch2332 } finally {2333 stateLock.unlock()2334 }2335 }23362337 2338 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23392340 private fun countDuplicateEntriesLocked(): Int {2341 val seen = mutableMapOf<String, VaultEntry>()2342 var duplicates = 02343 for (entry in entryList.filter { it.deletedAt == null }) {2344 val key = entry.duplicateKey()2345 val existing = seen[key]2346 if (existing == null) {2347 seen[key] = entry2348 } else {2349 duplicates++2350 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2351 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2352 if (entryStamp > existingStamp) seen[key] = entry2353 }2354 }2355 return duplicates2356 }23572358 private fun VaultEntry.duplicateKey(): String {2359 if (type == "note") return "note|${title.lowercase()}"2360 val passkeyData = passkey2361 if (passkeyData != null) {2362 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2363 }2364 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2365 }23662367 236823692370237123722373 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2374 val epoch = expectedEpoch ?: currentEpoch()2375 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2376 val slotA = store.loadSlotA() ?: return false2377 val vaultA = VaultCodec.encodeSlot(slotA)2378 val vaultB = store.loadSlotB() ?: slotA.blob2379 2380 2381 2382 2383 2384 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2385 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162386 val fakeBlob = ByteArray(target).also(rng::nextBytes)2387 val fakeSalt = VaultCrypto.generateSalt()2388 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2389 }2390 val history = store.loadVaultHistory()2391 val latest = history.snapshots.firstOrNull()2392 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2393 val entryCount = mutateIfCurrentEpoch(epoch) {2394 if (derivedKey != null && !decoyMode) {2395 bucketEntryCount(entryList.count { it.deletedAt == null })2396 } else {2397 null2398 }2399 } ?: return false2400 return try {2401 val snapshot = VaultSnapshot(2402 id = UUID.randomUUID().toString(),2403 savedAt = System.currentTimeMillis(),2404 reason = reason,2405 vault_a = vaultA,2406 vault_b = vaultB,2407 vault_d = vaultD,2408 entryCount = entryCount,2409 )2410 2411 2412 2413 withEpochCommit(epoch) {2414 store.writeVaultHistory(2415 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2416 )2417 } ?: false2418 } catch (_: Exception) {2419 false2420 }2421 }24222423 private fun bucketEntryCount(n: Int): Int = when {2424 n <= 0 -> 02425 n <= 10 -> 102426 n <= 25 -> 252427 n <= 50 -> 502428 n <= 100 -> 1002429 n <= 250 -> 2502430 n <= 500 -> 5002431 else -> ((n + 499) / 500) * 5002432 }24332434 243524362437243824392440244124422443 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2444 if (!entry.isCard()) return entry2445 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2446 val derived = if (digits.length >= 4) digits.takeLast(4) else null2447 return entry.copy(last4 = derived ?: entry.last4)2448 }24492450 245124522453245424552456 private fun autoPurgeExpiredTrash() {2457 val days = _settings.value.trashRetentionDays ?: return2458 if (days <= 0) return2459 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2460 val before = entryList.size2461 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2462 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2463 }24642465 2466 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }24672468 private fun currentPayloadLocked(): VaultPayload =2469 VaultPayload(2470 entries = entryList.toList(),2471 duressConfigured = duressConfigured,2472 rootKeyB64 = rootKey?.let(::b64) ?: "",2473 recoveryWordHash = recoveryWordHash,2474 recoveryWordsSet = recoveryWordsSet,2475 )24762477 24782479248024812482 private fun ensureRootKey(payload: VaultPayload) {2483 if (payload.rootKeyB64.isNotBlank()) {2484 rootKey = unb64(payload.rootKeyB64)2485 return2486 }2487 rootKey = generateRecoverableRootKey()2488 runCatching { persistSlotA(bumpVersion = false) }2489 }24902491 private fun publish() {2492 _entries.value = entryList.filter { it.deletedAt == null }2493 }24942495 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2496 var plaintext = VaultCodec.encode(currentPayload())2497 return try {2498 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2499 StoredVault(version = 1, salt = b64(salt), blob = blob)2500 } finally {2501 2502 2503 2504 VaultCrypto.zero(plaintext)2505 }2506 }25072508 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2509 2510 2511 2512 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2513 val noise = ByteArray(approxBytes).also(rng::nextBytes)2514 return store.writeSlotB(b64(noise))2515 }25162517 251825192520 private fun matchingCiphertextLength(slotA: StoredVault): Int =2521 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25222523 25242525252625272528 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2529 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162530 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2531 val fakeSalt = VaultCrypto.generateSalt()2532 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2533 }25342535 253625372538253925402541 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2542 val isConfigured = mutateState { duressConfigured }2543 return if (isConfigured) true else writeFakeDuressSlot(slotA)2544 }25452546 2547254825492550255125522553 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2554 val salt = unhex(b.salt)2555 val nonce = unhex(b.nonce)2556 val ct = unhex(b.ciphertext)2557 val tag = unhex(b.tag)2558 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2559 System.arraycopy(nonce, 0, it, 0, nonce.size)2560 System.arraycopy(ct, 0, it, nonce.size, ct.size)2561 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2562 }2563 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2564 }25652566 2567 private fun persistSlotA(2568 bumpVersion: Boolean,2569 snapshotReason: String? = "local_save",2570 expectedEpoch: Long? = null,2571 ): Boolean {2572 val snapshot = snapshotForPersist(expectedEpoch)2573 ?: throw IllegalStateException("vault_locked_or_stale")2574 return try {2575 2576 if (snapshot.decoy) {2577 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2578 val plaintext = VaultCodec.encode(snapshot.payload)2579 val blob = try {2580 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2581 } finally {2582 2583 2584 VaultCrypto.zero(plaintext)2585 }2586 beforeDurableCommit()2587 return withEpochCommit(snapshot.epoch) {2588 store.writeDecoySlot(prior.copy(blob = blob))2589 } ?: false2590 }2591 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2592 2593 2594 2595 2596 val priorB = store.loadSlotB()2597 beforeDurableCommit()2598 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2599 val plaintext = VaultCodec.encode(snapshot.payload)2600 val blob = try {2601 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2602 } finally {2603 2604 2605 VaultCrypto.zero(plaintext)2606 }2607 val next = prior.copy(blob = blob)2608 2609 2610 2611 2612 withEpochCommit(snapshot.epoch) {2613 if (!store.writeSlotA(next)) return@withEpochCommit false2614 try {2615 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2616 2617 2618 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2619 if (bumpVersion) store.stampLocalMutation()2620 } catch (e: Exception) {2621 store.writeSlotA(prior)2622 if (priorB != null) runCatching { store.writeSlotB(priorB) }2623 throw e2624 }2625 true2626 } ?: false2627 } finally {2628 2629 2630 VaultCrypto.zero(snapshot.key)2631 }2632 }26332634 2635263626372638263926402641 private fun commitVaultRotation(2642 priorA: StoredVault,2643 priorB: String?,2644 nextA: StoredVault,2645 expectedEpoch: Long,2646 ): Boolean {2647 beforeDurableCommit()2648 return withEpochCommit(expectedEpoch) {2649 if (!store.writeSlotA(nextA)) return@withEpochCommit false2650 2651 2652 2653 try {2654 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2655 2656 2657 2658 2659 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2660 store.stampLocalMutation()2661 } catch (_: Exception) {2662 rollbackRotation(priorA, priorB)2663 return@withEpochCommit false2664 }2665 true2666 } ?: false2667 }26682669 26702671267226732674 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2675 val failures = mutableListOf<String>()2676 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2677 .onFailure { failures += "writeSlotA: $it" }2678 if (priorB != null) {2679 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2680 .onFailure { failures += "writeSlotB: $it" }2681 }2682 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2683 }26842685 2686 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2687 try {2688 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2689 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2690 throw IllegalStateException("storage_write_failed")2691 }2692 } catch (e: Exception) {2693 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2694 throw e2695 }2696 2697 2698 2699 mutateIfCurrentEpoch(expectedEpoch) { publish() }2700 }2701}