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 990991992993994995996997998999 suspend fun installAdoptedDuressSlot(json: String): Boolean = mutex.withLock {1000 val b = BrowserSlot.parse(json) ?: return false1001 var stored: StoredVault? = null1002 var salt: ByteArray? = null1003 var nonce: ByteArray? = null1004 var ct: ByteArray? = null1005 var tag: ByteArray? = null1006 var blob: ByteArray? = null1007 try {1008 salt = unhex(b.salt)1009 nonce = unhex(b.nonce)1010 ct = unhex(b.ciphertext)1011 tag = unhex(b.tag)1012 blob = ByteArray(nonce.size + ct.size + tag.size).also {1013 System.arraycopy(nonce, 0, it, 0, nonce.size)1014 System.arraycopy(ct, 0, it, nonce.size, ct.size)1015 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)1016 }1017 stored = StoredVault(version = 1, salt = b64(salt), blob = b64(blob))1018 val expectedEpoch = sessionEpoch1019 beforeDurableCommit()1020 withEpochCommit(expectedEpoch) {1021 store.writeDecoySlot(stored)1022 } ?: false1023 } catch (_: Exception) {1024 false1025 } finally {1026 salt?.let(VaultCrypto::zero)1027 nonce?.let(VaultCrypto::zero)1028 ct?.let(VaultCrypto::zero)1029 tag?.let(VaultCrypto::zero)1030 blob?.let(VaultCrypto::zero)1031 }1032 }10331034 1035 suspend fun clearDuress(): Boolean = mutex.withLock {1036 1037 1038 1039 1040 val expectedEpoch = currentEpoch()1041 val replaced = withEpochCommit(expectedEpoch) {1042 store.loadSlotA()?.let { slotA -> runCatching { writeFakeDuressSlot(slotA) }.getOrDefault(false) }1043 ?: runCatching { store.clearDecoySlot(); true }.getOrDefault(false)1044 } ?: false1045 if (!replaced) return@withLock false1046 1047 1048 if (!isSessionEpochCurrent(expectedEpoch)) return false1049 val shouldClear = mutateIfCurrentEpoch(expectedEpoch) { derivedKey != null && !decoyMode } == true1050 if (shouldClear && mutateIfCurrentEpoch(expectedEpoch) { duressConfigured = false } != null) {1051 runCatching { persistSlotA(bumpVersion = false, expectedEpoch = expectedEpoch) }1052 }1053 true1054 }10551056 1057 private fun isGcmAuthenticationFailure(error: Throwable): Boolean {1058 var current: Throwable? = error1059 while (current != null) {1060 if (current is javax.crypto.AEADBadTagException) return true1061 1062 1063 1064 1065 if (current is javax.crypto.BadPaddingException) return true1066 current = current.cause1067 }1068 return false1069 }10701071 1072 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {1073 val expectedEpoch = currentEpoch()1074 val slotA = try {1075 store.loadSlotA()1076 } catch (_: Exception) {1077 return@withLock UnlockResult.Error("unlock_key_storage_failed")1078 } ?: return@withLock UnlockResult.Error("no_vault")1079 1080 1081 1082 1083 var stagedKey: ByteArray? = key.copyOf()1084 return try {1085 val encrypted = try {1086 unb64(slotA.blob)1087 } catch (_: Exception) {1088 return@withLock UnlockResult.Error("invalid_vault_blob")1089 }1090 1091 1092 1093 1094 if (encrypted.size < VaultCrypto.NONCE_SIZE + (VaultCrypto.TAG_LENGTH_BITS / 8)) {1095 return@withLock UnlockResult.Error("invalid_vault_blob")1096 }1097 val plaintext = try {1098 VaultCrypto.decrypt(encrypted, stagedKey!!, VaultCrypto.AAD_SLOT_A)1099 } catch (e: Exception) {1100 if (isGcmAuthenticationFailure(e)) {1101 return@withLock UnlockResult.WrongPassword1102 }1103 return@withLock UnlockResult.Error("unlock_key_decrypt_failed")1104 }1105 val payload = try {1106 VaultCodec.decode(plaintext)1107 } catch (_: Exception) {1108 return@withLock UnlockResult.Error("invalid_vault_payload")1109 } finally {1110 VaultCrypto.zero(plaintext)1111 }1112 val candidate = stagedKey ?: return UnlockResult.Error("unlock_key_missing")1113 val result = adoptSlotA(candidate, payload, expectedEpoch)1114 if (result is UnlockResult.Success) stagedKey = null1115 result1116 } finally {1117 stagedKey?.let(VaultCrypto::zero)1118 }1119 }11201121 fun lock() {1122 1123 1124 1125 1126 1127 1128 stateLock.lock()1129 try {1130 sessionEpoch = nextSessionEpoch(sessionEpoch)1131 clearSessionStateLocked()1132 } finally {1133 stateLock.unlock()1134 }1135 }11361137 @Volatile private var suppressNextBackgroundLockUntil: Long = 0L11381139 1140 1141 @VisibleForTesting1142 internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }11431144 114511461147114811491150115111521153115411551156 fun expectTransientBackground() {1157 suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS1158 }11591160 11611162116311641165 fun consumeTransientBackgroundSuppression(): Boolean {1166 val until = suppressNextBackgroundLockUntil1167 val active = until != 0L && nowMs() < until1168 suppressNextBackgroundLockUntil = 0L1169 return active1170 }11711172 11731174 11751176117711781179 11801181118211831184118511861187118811891190 1191 1192 1193 1194 1195 internal data class SyncMasterKeySnapshot(val epoch: Long, val key: ByteArray)11961197 internal fun syncMasterKeySnapshot(): SyncMasterKeySnapshot? = mutateState {1198 if (decoyMode || !_isUnlocked.value) null1199 else rootKey?.copyOf()?.let { SyncMasterKeySnapshot(sessionEpoch, it) }1200 }12011202 internal fun isSessionEpochCurrent(epoch: Long): Boolean =1203 mutateState { sessionEpoch == epoch }12041205 internal fun unlockedSessionEpoch(): Long? = mutateState {1206 if (derivedKey != null && _isUnlocked.value) sessionEpoch else null1207 }12081209 fun syncMasterKey(): ByteArray? = syncMasterKeySnapshot()?.key12101211 121212131214121512161217121812191220 fun sessionRootKey(): ByteArray? = mutateState {1221 if (!sessionDataAvailable) null else rootKey?.copyOf()1222 }12231224 12251226 fun syncVaultId(): String? = mutateState {1227 if (decoyMode || !_isUnlocked.value) null1228 else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }1229 }12301231 12321233123412351236 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {1237 val expectedEpoch = mutateState { if (derivedKey != null && !decoyMode) sessionEpoch else null }1238 ?: return false1239 installRootKey(generateRecoverableRootKey(), expectedEpoch)1240 }12411242 12431244124512461247124812491250125112521253125412551256125712581259 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {1260 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {1261 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")1262 }1263 val expectedEpoch = mutateState {1264 when {1265 derivedKey == null -> null1266 decoyMode -> Long.MIN_VALUE1267 else -> sessionEpoch1268 }1269 }1270 if (expectedEpoch == null) return AdoptVaultResult.Error("vault_locked")1271 if (expectedEpoch == Long.MIN_VALUE) return AdoptVaultResult.Error("JOIN_DECOY_MODE")1272 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")12731274 val incoming = hexToRootKey(rootKeyHex)1275 if (incoming.size != VaultCrypto.KEY_SIZE) {1276 VaultCrypto.zero(incoming)1277 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")1278 }1279 1280 1281 1282 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess1283 if (!roundTrips) {1284 VaultCrypto.zero(incoming)1285 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")1286 }12871288 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)1289 return if (installRootKey(incoming, expectedEpoch)) {1290 AdoptVaultResult.Success(vaultId)1291 } else {1292 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")1293 }1294 }12951296 12971298129913001301 fun previewRecoveryWords(rootKeyHex: String): List<String>? {1302 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null1303 val bytes = hexToRootKey(rootKeyHex)1304 return try {1305 val words = rootKeyToWords(bytes)1306 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null1307 } catch (_: Exception) {1308 null1309 } finally {1310 VaultCrypto.zero(bytes)1311 }1312 }13131314 13151316131713181319 private suspend fun installRootKey(nextRootKey: ByteArray, expectedEpoch: Long): Boolean {1320 1321 1322 1323 1324 1325 1326 data class Snapshot(1327 val key: ByteArray,1328 val priorRootKey: ByteArray,1329 val priorRecoveryHash: String?,1330 val priorRecoverySet: Boolean,1331 )1332 val snap = mutateIfCurrentEpoch(expectedEpoch) {1333 val k = derivedKey ?: return@mutateIfCurrentEpoch null1334 if (decoyMode) return@mutateIfCurrentEpoch null1335 val priorRoot = rootKey ?: return@mutateIfCurrentEpoch null1336 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)1337 } ?: run {1338 VaultCrypto.zero(nextRootKey)1339 return false1340 }1341 val priorSlot = store.loadSlotA() ?: run {1342 VaultCrypto.zero(snap.key)1343 VaultCrypto.zero(snap.priorRootKey)1344 VaultCrypto.zero(nextRootKey)1345 return false1346 }1347 val nextWords = rootKeyToWords(nextRootKey)13481349 return try {1350 beforeDurableCommit()1351 if (!takeVaultSnapshot("local_save", expectedEpoch)) throw IllegalStateException("snapshot_failed")1352 if (mutateIfCurrentEpoch(expectedEpoch) {1353 rootKey = nextRootKey1354 recoveryWordHash = recoveryWordsHash(nextWords)1355 recoveryWordsSet = false1356 } == null) throw IllegalStateException("stale_root_rotation")13571358 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)1359 ?: throw IllegalStateException("recovery_escrow_failed")1360 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)1361 ?: throw IllegalStateException("root_escrow_failed")1362 val plaintext = VaultCodec.encode(currentPayload())1363 val blob = try {1364 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))1365 } finally {1366 VaultCrypto.zero(plaintext)1367 }1368 val nextSlot = priorSlot.copy(1369 blob = blob,1370 recoveryEscrow = nextRecoveryEscrow,1371 rootKeyEscrow = nextRootKeyEscrow,1372 )1373 val committed = withEpochCommit(expectedEpoch) {1374 if (!store.writeSlotA(nextSlot)) return@withEpochCommit false1375 try {1376 if (!writeMatchingNoiseSlotB(nextSlot)) throw IllegalStateException("noise_slot_b_commit_failed")1377 1378 1379 if (!writeCompanionDuressSlot(nextSlot)) throw IllegalStateException("duress_slot_commit_failed")1380 store.stampLocalMutation()1381 } catch (_: Exception) {1382 store.writeSlotA(priorSlot)1383 return@withEpochCommit false1384 }1385 true1386 } ?: false1387 if (!committed) throw IllegalStateException("storage_write_failed")1388 mutateIfCurrentEpoch(expectedEpoch) { publish() }1389 true1390 } catch (_: Exception) {1391 val restored = mutateIfCurrentEpoch(expectedEpoch) {1392 rootKey?.let(VaultCrypto::zero)1393 rootKey = snap.priorRootKey.copyOf()1394 recoveryWordHash = snap.priorRecoveryHash1395 recoveryWordsSet = snap.priorRecoverySet1396 }1397 if (restored != null) {1398 withEpochCommit(expectedEpoch) { store.writeSlotA(priorSlot) }1399 }1400 false1401 } finally {1402 VaultCrypto.zero(snap.key)1403 VaultCrypto.zero(snap.priorRootKey)1404 1405 1406 1407 val stillOwnedBySession = mutateState {1408 sessionEpoch == expectedEpoch && rootKey === nextRootKey1409 }1410 if (!stillOwnedBySession) VaultCrypto.zero(nextRootKey)1411 }1412 }14131414 14151416141714181419 fun currentVaultKey(): ByteArray? = mutateState {1420 if (!isFullyUnlockedLocked()) null else derivedKey?.copyOf()1421 }14221423 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {1424 val expectedEpoch = currentEpoch()1425 val slotA = store.loadSlotA() ?: return false1426 var oldKey: ByteArray? = null1427 var newKey: ByteArray? = null1428 var plaintext: ByteArray? = null1429 return try {1430 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))1431 1432 1433 1434 1435 val decrypted = VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A)1436 val payload = try {1437 VaultCodec.decode(decrypted)1438 } finally {1439 VaultCrypto.zero(decrypted)1440 }1441 val newSalt = VaultCrypto.generateSalt()1442 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1443 1444 plaintext = VaultCodec.encode(payload)1445 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1446 1447 1448 1449 1450 1451 1452 1453 val next = StoredVault(1454 version = slotA.version,1455 salt = b64(newSalt),1456 blob = blob,1457 recoveryEscrow = slotA.recoveryEscrow,1458 rootKeyEscrow = slotA.rootKeyEscrow,1459 )1460 1461 1462 1463 1464 val priorB = store.loadSlotB()1465 if (!commitVaultRotation(slotA, priorB, next, expectedEpoch)) return false1466 1467 1468 1469 if (mutateIfCurrentEpoch(expectedEpoch) {1470 oldKey?.let(VaultCrypto::zero); oldKey = null1471 derivedKey?.let(VaultCrypto::zero)1472 derivedKey = newKey; newKey = null1473 } == null) return false1474 true1475 } catch (_: Exception) {1476 false1477 } finally {1478 oldKey?.let(VaultCrypto::zero)1479 newKey?.let(VaultCrypto::zero)1480 plaintext?.let(VaultCrypto::zero)1481 }1482 }14831484 1485148614871488 suspend fun destroyVault() = mutex.withLock {1489 lock()1490 store.clearVault()1491 store.clearDecoySlot()1492 }14931494 14951496 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {1497 val epoch = requireUnlocked()1498 val stamp = VaultCrypto.monotonicEntryTimestamp()1499 val now = System.currentTimeMillis()1500 val entry = withDerivedLast4(draft.copy(1501 id = draft.id.ifBlank { UUID.randomUUID().toString() },1502 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1503 modifiedAt = stamp,1504 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1505 ))1506 1507 1508 val rollback = mutateCurrentSession(epoch) {1509 val r = entryList.toMutableList()1510 entryList.add(entry)1511 r1512 } ?: throw IllegalStateException("vault_locked")1513 persistOrRollback(rollback, epoch)1514 entry1515 }15161517 151815191520152115221523152415251526 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {1527 val epoch = requireUnlocked()1528 if (drafts.isEmpty()) return 01529 val now = System.currentTimeMillis()1530 val ready = drafts.map { draft ->1531 val stamp = VaultCrypto.monotonicEntryTimestamp()1532 withDerivedLast4(1533 draft.copy(1534 id = draft.id.ifBlank { UUID.randomUUID().toString() },1535 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1536 modifiedAt = stamp,1537 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1538 ),1539 )1540 }1541 1542 val rollback = mutateCurrentSession(epoch) {1543 val r = entryList.toMutableList()1544 entryList.addAll(ready)1545 r1546 } ?: throw IllegalStateException("vault_locked")1547 persistOrRollback(rollback, epoch)1548 return drafts.size1549 }15501551 1552155315541555155615571558155915601561156215631564 suspend fun replaceEntries(newEntries: List<VaultEntry>): Int = mutex.withLock {1565 val epoch = requireUnlocked()1566 val now = System.currentTimeMillis()1567 val ready = newEntries.map { draft ->1568 val stamp = VaultCrypto.monotonicEntryTimestamp()1569 withDerivedLast4(1570 draft.copy(1571 id = draft.id.ifBlank { UUID.randomUUID().toString() },1572 createdAt = if (draft.createdAt > 0) draft.createdAt else now,1573 modifiedAt = stamp,1574 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,1575 ),1576 )1577 }1578 1579 1580 1581 1582 1583 1584 val incomingIds = ready.map { it.id }.toHashSet()1585 val rollback = mutateCurrentSession(epoch) {1586 val r = entryList.toMutableList()1587 val replaced = entryList.map { entry ->1588 if (entry.deletedAt != null) return@map entry1589 if (entry.id in incomingIds) return@map null1590 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1591 entry.copy(deletedAt = stamp, modifiedAt = stamp)1592 }.filterNotNull()1593 entryList = (replaced + ready).toMutableList()1594 r1595 } ?: throw IllegalStateException("vault_locked")1596 persistOrRollback(rollback, epoch)1597 return ready.size1598 }15991600 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1601 val epoch = requireUnlocked()1602 1603 1604 1605 1606 val prepared = mutateCurrentSession(epoch) {1607 val idx = entryList.indexOfFirst { it.id == patch.id }1608 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1609 val current = entryList[idx]1610 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1611 1612 val passwordChanged = patch.password != null && patch.password != current.password1613 1614 1615 1616 1617 1618 1619 val priorPassword = current.password1620 val shouldCapture = passwordChanged &&1621 !priorPassword.isNullOrEmpty() &&1622 current.passwordHistory?.firstOrNull()?.password != priorPassword1623 val nextHistory = if (shouldCapture) {1624 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1625 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1626 } else {1627 current.passwordHistory1628 }1629 val updated = withDerivedLast4(patch.copy(1630 createdAt = current.createdAt,1631 modifiedAt = stamp,1632 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1633 passwordHistory = nextHistory,1634 ))1635 val rollback = entryList.toMutableList()1636 entryList[idx] = updated1637 updated to rollback1638 } ?: throw IllegalStateException("vault_locked")1639 val (updated, rollback) = prepared1640 persistOrRollback(rollback, epoch)1641 updated1642 }16431644 1645 suspend fun deleteEntry(id: String) = mutex.withLock {1646 val epoch = requireUnlocked()1647 1648 1649 val rollback = mutateCurrentSession(epoch) {1650 val idx = entryList.indexOfFirst { it.id == id }1651 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1652 val current = entryList[idx]1653 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1654 val prior = entryList.toMutableList()1655 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1656 prior1657 } ?: throw IllegalStateException("vault_locked")1658 persistOrRollback(rollback, epoch)1659 }16601661 suspend fun restoreEntry(id: String) = mutex.withLock {1662 val epoch = requireUnlocked()1663 val rollback = mutateCurrentSession(epoch) {1664 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1665 if (idx == -1) throw IllegalStateException("deleted entry not found")1666 val current = entryList[idx]1667 val prior = entryList.toMutableList()1668 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1669 prior1670 } ?: throw IllegalStateException("vault_locked")1671 persistOrRollback(rollback, epoch)1672 }16731674 1675 suspend fun purgeEntry(id: String) = mutex.withLock {1676 val epoch = requireUnlocked()1677 val rollback = mutateCurrentSession(epoch) {1678 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1679 if (idx == -1) throw IllegalStateException("deleted entry not found")1680 val prior = entryList.toMutableList()1681 entryList.removeAt(idx)1682 prior1683 } ?: throw IllegalStateException("vault_locked")1684 persistOrRollback(rollback, epoch)1685 }16861687 1688 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1689 val epoch = requireUnlocked()1690 1691 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1692 val r = mutateCurrentSession(epoch) {1693 val rollback = entryList.toMutableList()1694 val before = entryList.size1695 entryList.removeAll { it.deletedAt != null }1696 R(rollback, before - entryList.size)1697 } ?: throw IllegalStateException("vault_locked")1698 if (r.removed > 0) persistOrRollback(r.rollback, epoch)1699 r.removed1700 }17011702 1703 suspend fun countDuplicateEntries(): Int = mutex.withLock {1704 val epoch = requireUnlocked()1705 1706 1707 mutateCurrentSession(epoch) { countDuplicateEntriesLocked() }1708 ?: throw IllegalStateException("vault_locked")1709 }17101711 17121713171417151716 suspend fun deduplicateEntries(): Int = mutex.withLock {1717 val epoch = requireUnlocked()1718 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1719 val ready = mutateCurrentSession(epoch) {1720 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1721 val active = entryList.filter { it.deletedAt == null }1722 val keepIds = mutableSetOf<String>()1723 active.groupBy { it.duplicateKey() }.values.forEach { group ->1724 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1725 }1726 val rollback = entryList.toMutableList()1727 var removed = 01728 entryList = entryList.map { entry ->1729 if (entry.deletedAt == null && entry.id !in keepIds) {1730 removed++1731 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1732 entry.copy(deletedAt = stamp, modifiedAt = stamp)1733 } else {1734 entry1735 }1736 }.toMutableList()1737 R(rollback, removed)1738 } ?: throw IllegalStateException("vault_locked")1739 if (ready.removed > 0) persistOrRollback(ready.rollback, epoch)1740 ready.removed1741 }17421743 fun getEntries(): List<VaultEntry> = mutateState {1744 if (!sessionDataAvailable) emptyList() else entryList.filter { it.deletedAt == null }1745 }1746 fun getEntry(id: String): VaultEntry? = mutateState {1747 if (!sessionDataAvailable) null else entryList.firstOrNull { it.id == id && it.deletedAt == null }1748 }1749 fun getDeletedEntries(): List<VaultEntry> = mutateState {1750 if (!sessionDataAvailable) emptyList()1751 else entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }1752 }17531754 1755 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1756 return mutateState {1757 if (!sessionDataAvailable) return@mutateState emptyList()1758 val active = entryList.filter { it.deletedAt == null }1759 if (tagFilter == null) return@mutateState active1760 val needle = tagFilter.trim().lowercase()1761 active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1762 }1763 }17641765 1766 fun getDistinctTags(): List<String> = mutateState {1767 if (!sessionDataAvailable) return@mutateState emptyList()1768 entryList.asSequence()1769 .filter { it.deletedAt == null }1770 .flatMap { it.tags?.asSequence() ?: emptySequence() }1771 .map { it.trim().lowercase() }1772 .filter { it.isNotEmpty() }1773 .distinct()1774 .sorted()1775 .toList()1776 }17771778 177917801781178217831784 fun getDistinctFieldValues(field: String): List<String> {1785 val select: (VaultEntry) -> String? = when (field) {1786 "username" -> { e -> e.username }1787 "email" -> { e -> e.email }1788 else -> return emptyList()1789 }1790 return mutateState {1791 if (!sessionDataAvailable) return@mutateState emptyList()1792 entryList.asSequence()1793 .filter { it.deletedAt == null }1794 .mapNotNull { select(it)?.trim() }1795 .filter { it.isNotEmpty() }1796 .groupingBy { it.lowercase() }1797 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1798 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1799 }1800 .values1801 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1802 .map { it.first }1803 }1804 }18051806 fun getTombstones(): List<Tombstone> = mutateState {1807 if (!sessionDataAvailable) emptyList()1808 else entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1809 }1810 fun getVaultVersion(): Long = store.version()1811 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots18121813 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1814 val expectedEpoch = currentEpoch()1815 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1816 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1817 1818 1819 val restoredD: StoredVault? = snapshot.vault_d?.let(VaultCodec::decodeSlot)1820 val priorA = store.loadSlotA()1821 val priorB = store.loadSlotB()1822 val priorD = store.loadDecoySlot()1823 1824 1825 1826 1827 1828 val priorHistory = store.loadVaultHistory()1829 1830 1831 1832 1833 beforeDurableCommit()1834 if (!takeVaultSnapshot("restore", expectedEpoch)) {1835 return false1836 }1837 1838 1839 1840 val nextD: StoredVault = restoredD ?: run {1841 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(restoredA) + 161842 val fakeBlob = ByteArray(target).also(rng::nextBytes)1843 val fakeSalt = VaultCrypto.generateSalt()1844 StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob))1845 }1846 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, nextD, priorA, priorB, priorD, expectedEpoch)) {1847 withEpochCommit(expectedEpoch) { store.writeVaultHistory(priorHistory) }1848 return false1849 }1850 lock()1851 true1852 }18531854 18551856185718581859186018611862186318641865 private fun commitRestoreAtomic(1866 nextA: StoredVault,1867 nextB: String,1868 nextD: StoredVault,1869 priorA: StoredVault?,1870 priorB: String?,1871 priorD: StoredVault?,1872 expectedEpoch: Long,1873 ): Boolean {1874 return withEpochCommit(expectedEpoch) {1875 if (!store.writeSlotA(nextA)) return@withEpochCommit false1876 1877 1878 1879 try {1880 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1881 1882 1883 if (!store.writeDecoySlot(nextD)) throw java.io.IOException("duress_slot_commit_failed")1884 store.stampLocalMutation()1885 } catch (_: Exception) {1886 reportRollbackFailures(rollbackRestoreLocked(priorA, priorB, priorD))1887 return@withEpochCommit false1888 }1889 true1890 } ?: false1891 }18921893 18941895 18961897189818991900 suspend fun applyMergedRemoteEntries(1901 remoteEntries: List<VaultEntry>,1902 remoteTombstones: List<Tombstone>,1903 expectedEpoch: Long? = null,1904 ): Int = mutex.withLock {1905 val epoch = expectedEpoch ?: requireUnlocked()1906 if (expectedEpoch != null && mutateIfCurrentEpoch(epoch) { true } != true) return@withLock 01907 1908 1909 1910 1911 1912 val mergeNow = System.currentTimeMillis()1913 val clamped = remoteEntries.map { entry ->1914 entry.copy(1915 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1916 deletedAt = entry.deletedAt?.let {1917 SyncLimits.clampSyncTimestamp(it, mergeNow)1918 },1919 )1920 }1921 val tombMap = remoteTombstones.associate {1922 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1923 }1924 1925 1926 val applied = mutateCurrentSession(epoch) {1927 var n = 01928 val byId = entryList.associateBy { it.id }.toMutableMap()19291930 for (remote in clamped) {1931 val tomb = tombMap[remote.id]1932 if (tomb != null && tomb >= remote.modifiedAt) continue1933 val local = byId[remote.id]1934 if (local == null) {1935 entryList.add(remote); byId[remote.id] = remote; n++1936 } else if (remote.modifiedAt > local.modifiedAt) {1937 val i = entryList.indexOfFirst { it.id == local.id }1938 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1939 }1940 }19411942 for ((id, deletedAt) in tombMap) {1943 val local = byId[id] ?: continue1944 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1945 if (deletedAt < local.modifiedAt) continue1946 val i = entryList.indexOfFirst { it.id == id }1947 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1948 }1949 n1950 } ?: return@withLock 019511952 if (applied > 0) {1953 persistSlotA(bumpVersion = false, snapshotReason = "sync_received", expectedEpoch = epoch)1954 mutateIfCurrentEpoch(epoch) { publish() }1955 }1956 applied1957 }19581959 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1960 1961 1962 1963 val epoch = requireUnlocked()1964 val prior = mutateCurrentSession(epoch) {1965 val old = _settings.value1966 _settings.value = settings1967 old1968 } ?: throw IllegalStateException("vault_locked")19691970 val committed = try {1971 beforeDurableCommit()1972 1973 1974 1975 withSessionEpochCommit(epoch) { store.writeSettings(settings) } == true1976 } catch (_: Exception) {1977 false1978 }1979 if (!committed) {1980 1981 1982 1983 mutateIfCurrentEpoch(epoch) { _settings.value = prior }1984 throw IllegalStateException("settings_write_failed")1985 }1986 }19871988 19891990 1991 fun isRecoveryWordsSet(): Boolean = mutateState { recoveryWordsSet }19921993 19941995199619971998 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1999 val epoch = requireUnlocked()2000 2001 2002 2003 2004 2005 2006 val prior = mutateCurrentSession(epoch) {2007 val priorPair = recoveryWordHash to recoveryWordsSet2008 recoveryWordHash = recoveryWordsHash(words)2009 recoveryWordsSet = true2010 priorPair2011 } ?: return@withLock false2012 2013 2014 2015 2016 val persisted = runCatching {2017 persistSlotAWithEscrows(2018 buildRecoveryEscrow(words),2019 buildRootKeyEscrow(words),2020 expectedEpoch = epoch,2021 )2022 }.getOrDefault(false)2023 if (!persisted) {2024 mutateIfCurrentEpoch(epoch) {2025 recoveryWordHash = prior.first2026 recoveryWordsSet = prior.second2027 }2028 }2029 persisted2030 }20312032 2033 fun verifyRecoveryWords(words: List<String>): Boolean {2034 return mutateState {2035 val stored = recoveryWordHash ?: return@mutateState false2036 stored == recoveryWordsHash(words)2037 }2038 }20392040 20412042 2043 fun hasRecoveryEscrow(): Boolean =2044 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false20452046 204720482049205020512052205320542055205620572058 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {2059 val expectedEpoch = currentEpoch()2060 val slotA = store.loadSlotA() ?: return false2061 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false20622063 2064 2065 val fullEscrow = slotA.recoveryEscrow2066 if (fullEscrow != null) {2067 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull()2068 if (material != null) {2069 var keyBytes: ByteArray? = null2070 var plaintext: ByteArray? = null2071 var candidateRoot: ByteArray? = null2072 var stateCommitStarted = false2073 try {2074 keyBytes = deriveRecoveryKey(material, unb64(fullEscrow.salt)).encoded2075 plaintext = VaultCrypto.decrypt(unb64(fullEscrow.blob), keyBytes!!, VaultCrypto.AAD_RECOVERY)2076 val payload = VaultCodec.decode(plaintext)2077 2078 2079 2080 2081 val candidateSettings = store.loadSettings()2082 candidateRoot = if (payload.rootKeyB64.isNotBlank()) {2083 val decodedRoot = unb64(payload.rootKeyB64)2084 if (decodedRoot.size != VaultCrypto.KEY_SIZE) {2085 2086 2087 2088 VaultCrypto.zero(decodedRoot)2089 throw IllegalArgumentException("invalid recovery root key")2090 }2091 decodedRoot2092 } else {2093 generateRecoverableRootKey()2094 }2095 stateCommitStarted = true2096 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2097 clearSessionStateLocked()2098 entryList = payload.entries.toMutableList()2099 duressConfigured = payload.duressConfigured2100 recoveryWordHash = payload.recoveryWordHash2101 recoveryWordsSet = payload.recoveryWordsSet2102 rootKey = candidateRoot2103 candidateRoot = null2104 _settings.value = candidateSettings2105 publish()2106 2107 2108 sessionDataAvailable = true2109 _isUnlocked.value = false2110 true2111 }2112 if (adopted == true) return@withLock true2113 } catch (_: Exception) {2114 2115 2116 2117 2118 if (stateCommitStarted) {2119 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2120 }2121 } finally {2122 plaintext?.let(VaultCrypto::zero)2123 VaultCrypto.zero(material)2124 keyBytes?.let(VaultCrypto::zero)2125 candidateRoot?.let(VaultCrypto::zero)2126 }2127 }2128 }21292130 2131 2132 2133 val rootEscrow = slotA.rootKeyEscrow2134 if (rootEscrow != null) {2135 var candidateRoot: ByteArray? = null2136 var stateCommitStarted = false2137 try {2138 candidateRoot = unsealRootKeyEscrow(words, rootEscrow) ?: return false2139 require(candidateRoot!!.size == VaultCrypto.KEY_SIZE) { "invalid recovery root key" }2140 val candidateSettings = store.loadSettings()2141 stateCommitStarted = true2142 val adopted = mutateIfCurrentEpoch(expectedEpoch) {2143 clearSessionStateLocked()2144 entryList = mutableListOf()2145 duressConfigured = false2146 recoveryWordHash = recoveryWordsHash(words)2147 recoveryWordsSet = true2148 rootKey = candidateRoot2149 candidateRoot = null2150 _settings.value = candidateSettings2151 publish()2152 sessionDataAvailable = true2153 _isUnlocked.value = false2154 true2155 }2156 if (adopted == true) return@withLock true2157 } catch (_: Exception) {2158 if (stateCommitStarted) {2159 mutateIfCurrentEpoch(expectedEpoch) { clearSessionStateLocked() }2160 }2161 } finally {2162 candidateRoot?.let(VaultCrypto::zero)2163 }2164 }2165 return false2166 }21672168 216921702171217221732174217521762177 suspend fun resetPasswordWithRecoveryWords(2178 words: List<String>,2179 newPassword: String,2180 ): Boolean = mutex.withLock {2181 val expectedEpoch = currentEpoch()2182 2183 2184 2185 if (mutateIfCurrentEpoch(expectedEpoch) { rootKey != null } != true) return false2186 val priorA = store.loadSlotA() ?: return false2187 var newKey: ByteArray? = null2188 var plaintext: ByteArray? = null2189 return try {2190 val newSalt = VaultCrypto.generateSalt()2191 newKey = VaultCrypto.deriveKey(newPassword, newSalt)2192 plaintext = VaultCodec.encode(currentPayload())2193 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))2194 2195 2196 2197 val valid = words.size == 12 && findInvalidWords(words).isEmpty()2198 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow2199 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow2200 val next = StoredVault(2201 version = priorA.version,2202 salt = b64(newSalt),2203 blob = blob,2204 recoveryEscrow = freshRecovery,2205 rootKeyEscrow = freshRootKey,2206 )2207 2208 2209 2210 val priorB = store.loadSlotB()2211 if (!commitVaultRotation(priorA, priorB, next, expectedEpoch)) return false2212 2213 2214 2215 if (mutateIfCurrentEpoch(expectedEpoch) {2216 derivedKey?.let(VaultCrypto::zero)2217 derivedKey = newKey; newKey = null2218 publish()2219 _isUnlocked.value = true2220 } == null) return false2221 true2222 } catch (_: Exception) {2223 false2224 } finally {2225 newKey?.let(VaultCrypto::zero)2226 plaintext?.let(VaultCrypto::zero)2227 }2228 }22292230 223122322233223422352236 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {2237 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2238 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2239 var keyBytes: ByteArray? = null2240 var plaintext: ByteArray? = null2241 return try {2242 val salt = VaultCrypto.generateSalt()2243 val recoveryKey = deriveRecoveryKey(material, salt)2244 keyBytes = recoveryKey.encoded2245 plaintext = VaultCodec.encode(currentPayload())2246 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)2247 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))2248 } finally {2249 VaultCrypto.zero(material)2250 keyBytes?.let(VaultCrypto::zero)2251 plaintext?.let(VaultCrypto::zero)2252 }2253 }22542255 22562257225822592260226122622263 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {2264 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null2265 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2266 val rk = mutateState { rootKey?.copyOf() } ?: run {2267 VaultCrypto.zero(material)2268 return null2269 }2270 var keyBytes: ByteArray? = null2271 var plaintext: ByteArray? = null2272 return try {2273 val salt = VaultCrypto.generateSalt()2274 val recoveryKey = deriveRecoveryKey(material, salt)2275 keyBytes = recoveryKey.encoded2276 plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)2277 2278 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2279 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)2280 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)2281 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)2282 val ct = ctTag.copyOfRange(0, ctTag.size - 16)2283 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))2284 } finally {2285 VaultCrypto.zero(rk)2286 VaultCrypto.zero(material)2287 keyBytes?.let(VaultCrypto::zero)2288 plaintext?.let(VaultCrypto::zero)2289 }2290 }22912292 2293 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {2294 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null2295 var keyBytes: ByteArray? = null2296 var pt: ByteArray? = null2297 return try {2298 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))2299 keyBytes = recoveryKey.encoded2300 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)2301 pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)2302 hexToRootKey(String(pt, Charsets.US_ASCII))2303 } catch (_: Exception) {2304 null2305 } finally {2306 2307 2308 2309 VaultCrypto.zero(material)2310 keyBytes?.let(VaultCrypto::zero)2311 pt?.let(VaultCrypto::zero)2312 }2313 }23142315 231623172318231923202321 private fun persistSlotAWithEscrows(2322 recoveryEscrow: RecoveryEscrow?,2323 rootKeyEscrow: RootKeyEscrow?,2324 expectedEpoch: Long? = null,2325 ): Boolean {2326 val snapshot = snapshotForPersist(expectedEpoch)2327 ?: throw IllegalStateException("vault_locked_or_stale")2328 if (snapshot.decoy) {2329 VaultCrypto.zero(snapshot.key)2330 return false2331 }2332 try {2333 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2334 val plaintext = VaultCodec.encode(snapshot.payload)2335 val blob = try {2336 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2337 } finally {2338 2339 2340 VaultCrypto.zero(plaintext)2341 }2342 val next = prior.copy(2343 blob = blob,2344 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,2345 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,2346 )2347 beforeDurableCommit()2348 return withEpochCommit(snapshot.epoch) {2349 if (!store.writeSlotA(next)) return@withEpochCommit false2350 try {2351 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2352 2353 2354 2355 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2356 } catch (e: Exception) {2357 store.writeSlotA(prior)2358 throw e2359 }2360 true2361 } ?: false2362 } finally {2363 VaultCrypto.zero(snapshot.key)2364 }2365 }23662367 23682369 private fun requireUnlocked(): Long {2370 2371 2372 2373 stateLock.lock()2374 try {2375 if (derivedKey == null) throw IllegalStateException("vault_locked")2376 return sessionEpoch2377 } finally {2378 stateLock.unlock()2379 }2380 }23812382 2383 private fun isFullyUnlockedLocked(): Boolean = _isUnlocked.value && derivedKey != null23842385 private fun countDuplicateEntriesLocked(): Int {2386 val seen = mutableMapOf<String, VaultEntry>()2387 var duplicates = 02388 for (entry in entryList.filter { it.deletedAt == null }) {2389 val key = entry.duplicateKey()2390 val existing = seen[key]2391 if (existing == null) {2392 seen[key] = entry2393 } else {2394 duplicates++2395 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt2396 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt2397 if (entryStamp > existingStamp) seen[key] = entry2398 }2399 }2400 return duplicates2401 }24022403 private fun VaultEntry.duplicateKey(): String {2404 if (type == "note") return "note|${title.lowercase()}"2405 val passkeyData = passkey2406 if (passkeyData != null) {2407 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"2408 }2409 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"2410 }24112412 241324142415241624172418 private fun takeVaultSnapshot(reason: String, expectedEpoch: Long? = null): Boolean {2419 val epoch = expectedEpoch ?: currentEpoch()2420 if (mutateIfCurrentEpoch(epoch) { true } != true) return false2421 val slotA = store.loadSlotA() ?: return false2422 val vaultA = VaultCodec.encodeSlot(slotA)2423 val vaultB = store.loadSlotB() ?: slotA.blob2424 2425 2426 2427 2428 2429 val vaultD = store.loadDecoySlot()?.let(VaultCodec::encodeSlot) ?: run {2430 val target = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162431 val fakeBlob = ByteArray(target).also(rng::nextBytes)2432 val fakeSalt = VaultCrypto.generateSalt()2433 VaultCodec.encodeSlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2434 }2435 val history = store.loadVaultHistory()2436 val latest = history.snapshots.firstOrNull()2437 if (latest?.vault_a == vaultA && latest.vault_b == vaultB && latest.vault_d == vaultD) return true2438 val entryCount = mutateIfCurrentEpoch(epoch) {2439 if (derivedKey != null && !decoyMode) {2440 bucketEntryCount(entryList.count { it.deletedAt == null })2441 } else {2442 null2443 }2444 } ?: return false2445 return try {2446 val snapshot = VaultSnapshot(2447 id = UUID.randomUUID().toString(),2448 savedAt = System.currentTimeMillis(),2449 reason = reason,2450 vault_a = vaultA,2451 vault_b = vaultB,2452 vault_d = vaultD,2453 entryCount = entryCount,2454 )2455 2456 2457 2458 withEpochCommit(epoch) {2459 store.writeVaultHistory(2460 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),2461 )2462 } ?: false2463 } catch (_: Exception) {2464 false2465 }2466 }24672468 private fun bucketEntryCount(n: Int): Int = when {2469 n <= 0 -> 02470 n <= 10 -> 102471 n <= 25 -> 252472 n <= 50 -> 502473 n <= 100 -> 1002474 n <= 250 -> 2502475 n <= 500 -> 5002476 else -> ((n + 499) / 500) * 5002477 }24782479 248024812482248324842485248624872488 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {2489 if (!entry.isCard()) return entry2490 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""2491 val derived = if (digits.length >= 4) digits.takeLast(4) else null2492 return entry.copy(last4 = derived ?: entry.last4)2493 }24942495 249624972498249925002501 private fun autoPurgeExpiredTrash() {2502 val days = _settings.value.trashRetentionDays ?: return2503 if (days <= 0) return2504 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L2505 val before = entryList.size2506 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }2507 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }2508 }25092510 2511 private fun currentPayload(): VaultPayload = mutateState { currentPayloadLocked() }25122513 private fun currentPayloadLocked(): VaultPayload =2514 VaultPayload(2515 entries = entryList.toList(),2516 duressConfigured = duressConfigured,2517 rootKeyB64 = rootKey?.let(::b64) ?: "",2518 recoveryWordHash = recoveryWordHash,2519 recoveryWordsSet = recoveryWordsSet,2520 )25212522 25232524252525262527 private fun ensureRootKey(payload: VaultPayload) {2528 if (payload.rootKeyB64.isNotBlank()) {2529 rootKey = unb64(payload.rootKeyB64)2530 return2531 }2532 rootKey = generateRecoverableRootKey()2533 runCatching { persistSlotA(bumpVersion = false) }2534 }25352536 private fun publish() {2537 _entries.value = entryList.filter { it.deletedAt == null }2538 }25392540 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {2541 var plaintext = VaultCodec.encode(currentPayload())2542 return try {2543 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))2544 StoredVault(version = 1, salt = b64(salt), blob = blob)2545 } finally {2546 2547 2548 2549 VaultCrypto.zero(plaintext)2550 }2551 }25522553 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {2554 2555 2556 2557 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)2558 val noise = ByteArray(approxBytes).also(rng::nextBytes)2559 return store.writeSlotB(b64(noise))2560 }25612562 256325642565 private fun matchingCiphertextLength(slotA: StoredVault): Int =2566 (unb64(slotA.blob).size - VaultCrypto.NONCE_SIZE - 16).coerceAtLeast(0)25672568 25692570257125722573 private fun writeFakeDuressSlot(slotA: StoredVault): Boolean {2574 val blobSize = VaultCrypto.NONCE_SIZE + matchingCiphertextLength(slotA) + 162575 val fakeBlob = ByteArray(blobSize).also(rng::nextBytes)2576 val fakeSalt = VaultCrypto.generateSalt()2577 return store.writeDecoySlot(StoredVault(version = 1, salt = b64(fakeSalt), blob = b64(fakeBlob)))2578 }25792580 258125822583258425852586 private fun writeCompanionDuressSlot(slotA: StoredVault): Boolean {2587 val isConfigured = mutateState { duressConfigured }2588 return if (isConfigured) true else writeFakeDuressSlot(slotA)2589 }25902591 2592259325942595259625972598 private fun browserDuressPassthrough(b: BrowserSlotA): StoredVault {2599 val salt = unhex(b.salt)2600 val nonce = unhex(b.nonce)2601 val ct = unhex(b.ciphertext)2602 val tag = unhex(b.tag)2603 val blob = ByteArray(nonce.size + ct.size + tag.size).also {2604 System.arraycopy(nonce, 0, it, 0, nonce.size)2605 System.arraycopy(ct, 0, it, nonce.size, ct.size)2606 System.arraycopy(tag, 0, it, nonce.size + ct.size, tag.size)2607 }2608 return StoredVault(version = 1, salt = b64(salt), blob = b64(blob))2609 }26102611 2612 private fun persistSlotA(2613 bumpVersion: Boolean,2614 snapshotReason: String? = "local_save",2615 expectedEpoch: Long? = null,2616 ): Boolean {2617 val snapshot = snapshotForPersist(expectedEpoch)2618 ?: throw IllegalStateException("vault_locked_or_stale")2619 return try {2620 2621 if (snapshot.decoy) {2622 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")2623 val plaintext = VaultCodec.encode(snapshot.payload)2624 val blob = try {2625 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2626 } finally {2627 2628 2629 VaultCrypto.zero(plaintext)2630 }2631 beforeDurableCommit()2632 return withEpochCommit(snapshot.epoch) {2633 store.writeDecoySlot(prior.copy(blob = blob))2634 } ?: false2635 }2636 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")2637 2638 2639 2640 2641 val priorB = store.loadSlotB()2642 beforeDurableCommit()2643 if (snapshotReason != null && !takeVaultSnapshot(snapshotReason, snapshot.epoch)) return false2644 val plaintext = VaultCodec.encode(snapshot.payload)2645 val blob = try {2646 b64(VaultCrypto.encrypt(plaintext, snapshot.key, VaultCrypto.AAD_SLOT_A))2647 } finally {2648 2649 2650 VaultCrypto.zero(plaintext)2651 }2652 val next = prior.copy(blob = blob)2653 2654 2655 2656 2657 withEpochCommit(snapshot.epoch) {2658 if (!store.writeSlotA(next)) return@withEpochCommit false2659 try {2660 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")2661 2662 2663 if (!writeCompanionDuressSlot(next)) throw java.io.IOException("duress_slot_commit_failed")2664 if (bumpVersion) store.stampLocalMutation()2665 } catch (e: Exception) {2666 store.writeSlotA(prior)2667 if (priorB != null) runCatching { store.writeSlotB(priorB) }2668 throw e2669 }2670 true2671 } ?: false2672 } finally {2673 2674 2675 VaultCrypto.zero(snapshot.key)2676 }2677 }26782679 2680268126822683268426852686 private fun commitVaultRotation(2687 priorA: StoredVault,2688 priorB: String?,2689 nextA: StoredVault,2690 expectedEpoch: Long,2691 ): Boolean {2692 beforeDurableCommit()2693 return withEpochCommit(expectedEpoch) {2694 if (!store.writeSlotA(nextA)) return@withEpochCommit false2695 2696 2697 2698 try {2699 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")2700 2701 2702 2703 2704 if (!writeCompanionDuressSlot(nextA)) throw java.io.IOException("duress_slot_commit_failed")2705 store.stampLocalMutation()2706 } catch (_: Exception) {2707 rollbackRotation(priorA, priorB)2708 return@withEpochCommit false2709 }2710 true2711 } ?: false2712 }27132714 27152716271727182719 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {2720 val failures = mutableListOf<String>()2721 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }2722 .onFailure { failures += "writeSlotA: $it" }2723 if (priorB != null) {2724 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }2725 .onFailure { failures += "writeSlotB: $it" }2726 }2727 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }2728 }27292730 2731 private fun persistOrRollback(rollback: MutableList<VaultEntry>, expectedEpoch: Long) {2732 try {2733 if (!persistSlotA(bumpVersion = true, expectedEpoch = expectedEpoch)) {2734 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2735 throw IllegalStateException("storage_write_failed")2736 }2737 } catch (e: Exception) {2738 mutateIfCurrentEpoch(expectedEpoch) { entryList = rollback }2739 throw e2740 }2741 2742 2743 2744 mutateIfCurrentEpoch(expectedEpoch) { publish() }2745 }2746}