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.util.Log12import com.peachpasswords.BuildConfig13import com.peachpasswords.sync.SyncLimits14import kotlinx.serialization.json.JsonElement15import kotlinx.serialization.json.JsonNull16import kotlinx.serialization.json.JsonObject17import kotlinx.serialization.json.JsonPrimitive18import java.util.Base6419import java.util.UUID20import java.util.concurrent.locks.ReentrantLock21import javax.inject.Inject22import javax.inject.Singleton232425private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2627private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)28private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)293031private const val PASSWORD_HISTORY_MAX = 5032private const val VAULT_HISTORY_MAX = 53334sealed interface UnlockResult {35 data object Success : UnlockResult36 data object WrongPassword : UnlockResult37 data class Error(val reason: String) : UnlockResult38}3940sealed interface CreateVaultResult {41 data object Success : CreateVaultResult42 data class Error(val reason: String) : CreateVaultResult43}4445sealed interface AdoptVaultResult {46 data class Success(val vaultId: String) : AdoptVaultResult47 data class Error(val reason: String) : AdoptVaultResult48}495051525354555657585960616263@Singleton64class VaultRepository @Inject constructor(65 private val store: VaultStorage,66) {67 private val mutex = Mutex()68 69 70 71 72 73 74 75 private val stateLock = ReentrantLock()76 private val rng = SecureRandom()7778 7980818283 private inline fun <T> mutateState(block: () -> T): T {84 stateLock.lock()85 return try { block() } finally { stateLock.unlock() }86 }8788 private var derivedKey: ByteArray? = null89 private var rootKey: ByteArray? = null90 private var entryList: MutableList<VaultEntry> = mutableListOf()91 private var duressConfigured: Boolean = false92 private var recoveryWordHash: String? = null93 private var recoveryWordsSet: Boolean = false94 95 private var decoyMode: Boolean = false9697 private val _isUnlocked = MutableStateFlow(false)98 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()99100 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())101 102 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()103104 private val _settings = MutableStateFlow(VaultSettings())105 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()106107 fun hasVault(): Boolean = store.hasVault()108109 110111 112113114115116 fun codexVaultBlobBase64(): String? {117 val slotA = store.loadSlotA() ?: return null118 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""119 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))120 }121122 123124125126127 128 private fun JsonElement?.asSlotJson(): String? = when {129 this == null || this is JsonNull -> null130 this is JsonPrimitive && isString -> content131 else -> toString()132 }133134 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {135 var candidateKey: ByteArray? = null136 var plaintext: ByteArray? = null137 return try {138 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)139 140 141 142 143 144 145 146 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject147 ?: return UnlockResult.Error("invalid_codex_blob")148 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")149 150 151 152 153 154 val slotA = VaultCodec.decodeSlot(slotJson)155 ?: BrowserSlot.parse(slotJson)?.let {156 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword157 }158 ?: return UnlockResult.Error("invalid_codex_blob")159 160 161 162 163 164 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))165 plaintext = decryptSlotABlob(slotA, candidateKey)166 167 168 169 170 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {171 VaultCrypto.zero(candidateKey)172 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))173 plaintext = decryptSlotABlob(slotA, candidateKey)174 }175 if (plaintext == null) return UnlockResult.WrongPassword176 val payload = try {177 VaultCodec.decode(plaintext)178 } catch (_: Exception) {179 return UnlockResult.Error("invalid_codex_blob")180 }181182 val priorA = store.loadSlotA()183 val priorB = store.loadSlotB()184 185 186 val codexSlotB = wrapper["b"].asSlotJson()187188 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")189 val committed = runCatching {190 val wroteB = if (codexSlotB != null) {191 store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))192 } else {193 writeMatchingNoiseSlotB(slotA)194 }195 if (!wroteB) throw IllegalStateException("storage_write_failed")196 store.stampLocalMutation()197 }.isSuccess198 if (!committed) {199 rollbackRestore(priorA, priorB)200 return UnlockResult.Error("storage_write_failed")201 }202203 val ownedKey = candidateKey!!204 candidateKey = null 205 adoptSlotA(ownedKey, payload)206 } catch (e: Exception) {207 208 209 210 211 212 213 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))214 UnlockResult.Error(e.message ?: "restore_failed")215 } finally {216 plaintext?.let(VaultCrypto::zero)217 candidateKey?.let(VaultCrypto::zero)218 }219 }220221 222 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =223 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()224225 226227228229230231 private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {232 val failures = mutableListOf<String>()233 if (priorA == null) {234 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }235 } else {236 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }237 .onFailure { failures += "writeSlotA: $it" }238 if (priorB != null) {239 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }240 .onFailure { failures += "writeSlotB: $it" }241 }242 }243 244 245 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }246 }247248 249 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {250 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)251 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()252 val window = offset?.let {253 val from = (it - 40).coerceAtLeast(0)254 val to = (it + 40).coerceAtMost(wrapperJson.length)255 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")256 }257 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()258 val slot = wrapper?.get("a")259 buildString {260 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)261 append(" | blobBase64=").append(vaultBlobBase64.length)262 append(" wrapperChars=").append(wrapperJson.length)263 append(" wrapperParses=").append(wrapper != null)264 append(" keys=").append(wrapper?.keys)265 append(" slotAKind=").append(266 when {267 slot == null -> "absent"268 slot is JsonPrimitive && slot.isString -> "string"269 slot is JsonObject -> "object:" + slot.keys270 else -> slot::class.simpleName271 },272 )273 if (window != null) append(" masked@").append(offset).append("=").append(window)274 }275 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }276277 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {278 return try {279 val salt = VaultCrypto.generateSalt()280 val key = VaultCrypto.deriveKey(password, salt)281 mutateState {282 entryList = mutableListOf()283 duressConfigured = false284 recoveryWordHash = null285 recoveryWordsSet = false286 decoyMode = false287 rootKey = generateRecoverableRootKey()288 }289 val slotA = sealSlotA(key, salt)290 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")291 292 293 val created = try {294 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")295 store.stampLocalMutation()296 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")297 true298 } catch (_: Exception) {299 300 301 store.clearVault()302 false303 }304 if (!created) return CreateVaultResult.Error("storage_write_failed")305 mutateState {306 derivedKey = key307 publish()308 _isUnlocked.value = true309 }310 CreateVaultResult.Success311 } catch (e: Exception) {312 CreateVaultResult.Error(e.message ?: "create_failed")313 }314 }315316 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {317 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")318 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")319 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {320 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")321 }322 var adoptedRootKey: ByteArray? = null323 var nextDerivedKey: ByteArray? = null324 var sessionRootKey: ByteArray? = null325 return try {326 adoptedRootKey = hexToRootKey(rootKeyHex)327 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {328 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")329 }330 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)331 val salt = VaultCrypto.generateSalt()332 nextDerivedKey = VaultCrypto.deriveKey(password, salt)333 sessionRootKey = adoptedRootKey.copyOf()334 val payload = VaultPayload(335 entries = emptyList(),336 duressConfigured = false,337 rootKeyB64 = b64(sessionRootKey),338 recoveryWordHash = null,339 recoveryWordsSet = false,340 )341 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))342 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)343 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")344 345 346 val created = try {347 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")348 store.stampLocalMutation()349 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")350 true351 } catch (_: Exception) {352 store.clearVault()353 false354 }355 if (!created) return AdoptVaultResult.Error("storage_write_failed")356357 val previousDerivedKey = derivedKey358 val previousRootKey = rootKey359 mutateState {360 derivedKey = nextDerivedKey361 rootKey = sessionRootKey362 nextDerivedKey = null363 sessionRootKey = null364 entryList = mutableListOf()365 duressConfigured = false366 recoveryWordHash = null367 recoveryWordsSet = false368 decoyMode = false369 publish()370 _isUnlocked.value = true371 }372 previousDerivedKey?.let(VaultCrypto::zero)373 previousRootKey?.let(VaultCrypto::zero)374 AdoptVaultResult.Success(vaultId)375 } catch (e: Exception) {376 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")377 } finally {378 adoptedRootKey?.let(VaultCrypto::zero)379 nextDerivedKey?.let(VaultCrypto::zero)380 sessionRootKey?.let(VaultCrypto::zero)381 }382 }383384 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }385386 387 private fun unlockLocked(password: String): UnlockResult {388 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")389 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))390 val payload = try {391 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))392 } catch (_: Exception) {393 VaultCrypto.zero(key)394 395 396 397 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {398 val legacy = tryLegacyRawUnlock(password, slotA)399 if (legacy != null) return legacy400 }401 return UnlockResult.WrongPassword402 }403 return adoptSlotA(key, payload)404 }405406 407408409410411412413414 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {415 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))416 val payload = try {417 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))418 } catch (_: Exception) {419 VaultCrypto.zero(rawKey)420 return null421 }422 423 424 val newSalt = VaultCrypto.generateSalt()425 val newKey = VaultCrypto.deriveKey(password, newSalt)426 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))427 val next = StoredVault(428 version = slotA.version,429 salt = b64(newSalt),430 blob = blob,431 recoveryEscrow = slotA.recoveryEscrow,432 rootKeyEscrow = slotA.rootKeyEscrow,433 )434 435 436 437 val priorB = store.loadSlotB()438 val healed = if (!store.writeSlotA(next)) {439 false440 } else try {441 if (!writeMatchingNoiseSlotB(next)) {442 runCatching { store.writeSlotA(slotA) }443 if (priorB != null) runCatching { store.writeSlotB(priorB) }444 false445 } else {446 store.stampLocalMutation()447 true448 }449 } catch (_: Exception) {450 runCatching { store.writeSlotA(slotA) }451 if (priorB != null) runCatching { store.writeSlotB(priorB) }452 false453 }454 return if (healed) {455 VaultCrypto.zero(rawKey)456 adoptSlotA(newKey, payload) 457 } else {458 VaultCrypto.zero(newKey)459 adoptSlotA(rawKey, payload) 460 }461 }462463 464 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {465 return try {466 mutateState {467 derivedKey = key468 decoyMode = false469 entryList = payload.entries.toMutableList()470 duressConfigured = payload.duressConfigured471 recoveryWordHash = payload.recoveryWordHash472 recoveryWordsSet = payload.recoveryWordsSet473 _settings.value = store.loadSettings()474 ensureRootKey(payload)475 autoPurgeExpiredTrash()476 publish()477 _isUnlocked.value = true478 }479 UnlockResult.Success480 } catch (e: Exception) {481 UnlockResult.Error(e.message ?: "unlock_failed")482 }483 }484485 486487 488489490491492493 class SlotAUnlock internal constructor(494 internal val key: ByteArray,495 internal val payload: VaultPayload,496 )497498 499500501502503504 fun tryUnlockSlotA(password: String): SlotAUnlock? {505 val slotA = store.loadSlotA() ?: return null506 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))507 return try {508 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))509 SlotAUnlock(key, payload)510 } catch (_: Exception) {511 VaultCrypto.zero(key)512 null513 }514 }515516 517 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {518 adoptSlotA(unlock.key, unlock.payload)519 }520521 522 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)523524 525526 527528529530531 class DuressUnlock internal constructor(532 internal val key: ByteArray,533 internal val payload: VaultPayload,534 )535536 537538539540541542543 fun tryUnlockDuress(password: String): DuressUnlock? {544 val slot = store.loadDecoySlot() ?: return null545 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))546 return try {547 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))548 DuressUnlock(key, payload)549 } catch (_: Exception) {550 VaultCrypto.zero(key)551 null552 }553 }554555 556 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {557 return try {558 mutateState {559 derivedKey = unlock.key560 decoyMode = true561 entryList = unlock.payload.entries.toMutableList()562 duressConfigured = false563 recoveryWordHash = unlock.payload.recoveryWordHash564 recoveryWordsSet = unlock.payload.recoveryWordsSet565 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)566 else VaultCrypto.generateRootKey()567 _settings.value = store.loadSettings()568 publish()569 _isUnlocked.value = true570 }571 UnlockResult.Success572 } catch (e: Exception) {573 UnlockResult.Error(e.message ?: "decoy_unlock_failed")574 }575 }576577 578 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)579580 581582583584585586 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {587 588 val unlockedAndReal = mutateState { derivedKey != null && !decoyMode }589 if (!unlockedAndReal) return false590 591 592 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }593 return try {594 val salt = VaultCrypto.generateSalt()595 val key = VaultCrypto.deriveKey(duressPassphrase, salt)596 597 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))598 val plaintext = VaultCodec.encode(decoyPayload)599 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))600 VaultCrypto.zero(key)601 VaultCrypto.zero(plaintext)602 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))603 if (ok) {604 mutateState { duressConfigured = true }605 runCatching { persistSlotA(bumpVersion = false) }606 }607 ok608 } catch (_: Exception) {609 false610 }611 }612613 614 suspend fun clearDuress(): Boolean = mutex.withLock {615 store.clearDecoySlot()616 617 618 val shouldClear = mutateState { derivedKey != null && !decoyMode }619 if (shouldClear) {620 mutateState { duressConfigured = false }621 runCatching { persistSlotA(bumpVersion = false) }622 }623 true624 }625626 627 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {628 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")629 return try {630 val payload = VaultCodec.decode(631 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),632 )633 mutateState {634 derivedKey = key.copyOf()635 decoyMode = false636 entryList = payload.entries.toMutableList()637 duressConfigured = payload.duressConfigured638 recoveryWordHash = payload.recoveryWordHash639 recoveryWordsSet = payload.recoveryWordsSet640 _settings.value = store.loadSettings()641 ensureRootKey(payload)642 autoPurgeExpiredTrash()643 publish()644 _isUnlocked.value = true645 }646 UnlockResult.Success647 } catch (_: Exception) {648 UnlockResult.WrongPassword649 }650 }651652 fun lock() {653 654 655 656 657 stateLock.lock()658 try {659 derivedKey?.let(VaultCrypto::zero)660 rootKey?.let(VaultCrypto::zero)661 derivedKey = null662 rootKey = null663 entryList = mutableListOf()664 duressConfigured = false665 recoveryWordHash = null666 recoveryWordsSet = false667 decoyMode = false668 _entries.value = emptyList()669 _isUnlocked.value = false670 } finally {671 stateLock.unlock()672 }673 }674675 @Volatile private var suppressNextBackgroundLock = false676677 678679680681682683684685686 fun expectTransientBackground() {687 suppressNextBackgroundLock = true688 }689690 691 fun consumeTransientBackgroundSuppression(): Boolean {692 val suppressed = suppressNextBackgroundLock693 suppressNextBackgroundLock = false694 return suppressed695 }696697 698699 700701702703704 705706707708709710711712713714715 fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()716717 718719720721722723724725726 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()727728 729730 fun syncVaultId(): String? =731 if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }732733 734735736737738 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {739 installRootKey(generateRecoverableRootKey())740 }741742 743744745746747748749750751752753754755756757758759 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {760 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {761 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")762 }763 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")764 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")765 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")766767 val incoming = hexToRootKey(rootKeyHex)768 if (incoming.size != VaultCrypto.KEY_SIZE) {769 VaultCrypto.zero(incoming)770 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")771 }772 773 774 775 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess776 if (!roundTrips) {777 VaultCrypto.zero(incoming)778 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")779 }780781 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)782 return if (installRootKey(incoming)) {783 AdoptVaultResult.Success(vaultId)784 } else {785 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")786 }787 }788789 790791792793794 fun previewRecoveryWords(rootKeyHex: String): List<String>? {795 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null796 val bytes = hexToRootKey(rootKeyHex)797 return try {798 val words = rootKeyToWords(bytes)799 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null800 } catch (_: Exception) {801 null802 } finally {803 VaultCrypto.zero(bytes)804 }805 }806807 808809810811812 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {813 814 815 816 817 818 819 data class Snapshot(820 val key: ByteArray,821 val priorRootKey: ByteArray,822 val priorRecoveryHash: String?,823 val priorRecoverySet: Boolean,824 )825 val snap = mutateState {826 val k = derivedKey ?: return false827 if (decoyMode) return false828 val priorRoot = rootKey ?: return false829 Snapshot(k.copyOf(), priorRoot.copyOf(), recoveryWordHash, recoveryWordsSet)830 } as Snapshot? ?: return false831 val priorSlot = store.loadSlotA() ?: run {832 VaultCrypto.zero(snap.priorRootKey)833 return false834 }835 val nextWords = rootKeyToWords(nextRootKey)836837 return try {838 takeVaultSnapshot("local_save")839 mutateState {840 rootKey = nextRootKey841 recoveryWordHash = recoveryWordsHash(nextWords)842 recoveryWordsSet = false843 }844845 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)846 ?: throw IllegalStateException("recovery_escrow_failed")847 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)848 ?: throw IllegalStateException("root_escrow_failed")849 val plaintext = VaultCodec.encode(currentPayload())850 val blob = try {851 b64(VaultCrypto.encrypt(plaintext, snap.key, VaultCrypto.AAD_SLOT_A))852 } finally {853 VaultCrypto.zero(plaintext)854 }855 val nextSlot = priorSlot.copy(856 blob = blob,857 recoveryEscrow = nextRecoveryEscrow,858 rootKeyEscrow = nextRootKeyEscrow,859 )860 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")861 try {862 writeMatchingNoiseSlotB(nextSlot)863 store.stampLocalMutation()864 } catch (e: Exception) {865 store.writeSlotA(priorSlot)866 throw e867 }868 VaultCrypto.zero(snap.priorRootKey)869 mutateState { publish() }870 true871 } catch (_: Exception) {872 mutateState {873 rootKey = snap.priorRootKey874 recoveryWordHash = snap.priorRecoveryHash875 recoveryWordsSet = snap.priorRecoverySet876 }877 VaultCrypto.zero(nextRootKey)878 store.writeSlotA(priorSlot)879 false880 }881 }882883 884885886887888 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()889890 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {891 val slotA = store.loadSlotA() ?: return false892 var oldKey: ByteArray? = null893 var newKey: ByteArray? = null894 var plaintext: ByteArray? = null895 return try {896 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))897 val payload = VaultCodec.decode(898 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),899 )900 val newSalt = VaultCrypto.generateSalt()901 newKey = VaultCrypto.deriveKey(newPassword, newSalt)902 903 plaintext = VaultCodec.encode(payload)904 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))905 906 907 908 909 910 911 912 val next = StoredVault(913 version = slotA.version,914 salt = b64(newSalt),915 blob = blob,916 recoveryEscrow = slotA.recoveryEscrow,917 rootKeyEscrow = slotA.rootKeyEscrow,918 )919 920 921 922 923 val priorB = store.loadSlotB()924 if (!commitVaultRotation(slotA, priorB, next)) return false925 926 927 928 mutateState {929 oldKey?.let(VaultCrypto::zero); oldKey = null930 derivedKey?.let(VaultCrypto::zero)931 derivedKey = newKey; newKey = null932 }933 true934 } catch (_: Exception) {935 false936 } finally {937 oldKey?.let(VaultCrypto::zero)938 newKey?.let(VaultCrypto::zero)939 plaintext?.let(VaultCrypto::zero)940 }941 }942943 944945946947 suspend fun destroyVault() = mutex.withLock {948 lock()949 store.clearVault()950 store.clearDecoySlot()951 }952953 954955 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {956 requireUnlocked()957 val stamp = VaultCrypto.monotonicEntryTimestamp()958 val now = System.currentTimeMillis()959 val entry = withDerivedLast4(draft.copy(960 id = draft.id.ifBlank { UUID.randomUUID().toString() },961 createdAt = if (draft.createdAt > 0) draft.createdAt else now,962 modifiedAt = stamp,963 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,964 ))965 966 967 val rollback = mutateState {968 val r = entryList.toMutableList()969 entryList.add(entry)970 r971 }972 persistOrRollback(rollback)973 entry974 }975976 977978979980981982983984985 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {986 requireUnlocked()987 if (drafts.isEmpty()) return 0988 val now = System.currentTimeMillis()989 val ready = drafts.map { draft ->990 val stamp = VaultCrypto.monotonicEntryTimestamp()991 withDerivedLast4(992 draft.copy(993 id = draft.id.ifBlank { UUID.randomUUID().toString() },994 createdAt = if (draft.createdAt > 0) draft.createdAt else now,995 modifiedAt = stamp,996 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,997 ),998 )999 }1000 1001 val rollback = mutateState {1002 val r = entryList.toMutableList()1003 entryList.addAll(ready)1004 r1005 }1006 persistOrRollback(rollback)1007 return drafts.size1008 }10091010 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {1011 requireUnlocked()1012 1013 1014 1015 1016 var prepared: Pair<VaultEntry, MutableList<VaultEntry>>? = null1017 mutateState {1018 val idx = entryList.indexOfFirst { it.id == patch.id }1019 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1020 val current = entryList[idx]1021 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1022 1023 val passwordChanged = patch.password != null && patch.password != current.password1024 1025 1026 1027 1028 1029 1030 val priorPassword = current.password1031 val shouldCapture = passwordChanged &&1032 !priorPassword.isNullOrEmpty() &&1033 current.passwordHistory?.firstOrNull()?.password != priorPassword1034 val nextHistory = if (shouldCapture) {1035 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +1036 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)1037 } else {1038 current.passwordHistory1039 }1040 val updated = withDerivedLast4(patch.copy(1041 createdAt = current.createdAt,1042 modifiedAt = stamp,1043 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,1044 passwordHistory = nextHistory,1045 ))1046 val rollback = entryList.toMutableList()1047 entryList[idx] = updated1048 prepared = updated to rollback1049 }1050 val (updated, rollback) = prepared!!1051 persistOrRollback(rollback)1052 updated1053 }10541055 1056 suspend fun deleteEntry(id: String) = mutex.withLock {1057 requireUnlocked()1058 1059 1060 var rollback: MutableList<VaultEntry>? = null1061 mutateState {1062 val idx = entryList.indexOfFirst { it.id == id }1063 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")1064 val current = entryList[idx]1065 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)1066 rollback = entryList.toMutableList()1067 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)1068 }1069 persistOrRollback(rollback!!)1070 }10711072 suspend fun restoreEntry(id: String) = mutex.withLock {1073 requireUnlocked()1074 var rollback: MutableList<VaultEntry>? = null1075 mutateState {1076 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1077 if (idx == -1) throw IllegalStateException("deleted entry not found")1078 val current = entryList[idx]1079 rollback = entryList.toMutableList()1080 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))1081 }1082 persistOrRollback(rollback!!)1083 }10841085 1086 suspend fun purgeEntry(id: String) = mutex.withLock {1087 requireUnlocked()1088 var rollback: MutableList<VaultEntry>? = null1089 mutateState {1090 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }1091 if (idx == -1) throw IllegalStateException("deleted entry not found")1092 rollback = entryList.toMutableList()1093 entryList.removeAt(idx)1094 }1095 persistOrRollback(rollback!!)1096 }10971098 1099 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1100 requireUnlocked()1101 1102 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1103 val r = mutateState {1104 val rollback = entryList.toMutableList()1105 val before = entryList.size1106 entryList.removeAll { it.deletedAt != null }1107 R(rollback, before - entryList.size)1108 }1109 if (r.removed > 0) persistOrRollback(r.rollback)1110 r.removed1111 }11121113 1114 suspend fun countDuplicateEntries(): Int = mutex.withLock {1115 requireUnlocked()1116 1117 1118 mutateState { countDuplicateEntriesLocked() }1119 }11201121 11221123112411251126 suspend fun deduplicateEntries(): Int = mutex.withLock {1127 requireUnlocked()1128 1129 1130 val decoy = mutateState { decoyMode }1131 if (decoy) throw IllegalStateException("deduplicate disabled in decoy mode")1132 data class R(val rollback: MutableList<VaultEntry>, val removed: Int)1133 val ready = mutateState {1134 val active = entryList.filter { it.deletedAt == null }1135 val keepIds = mutableSetOf<String>()1136 active.groupBy { it.duplicateKey() }.values.forEach { group ->1137 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1138 }1139 val rollback = entryList.toMutableList()1140 var removed = 01141 entryList = entryList.map { entry ->1142 if (entry.deletedAt == null && entry.id !in keepIds) {1143 removed++1144 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1145 entry.copy(deletedAt = stamp, modifiedAt = stamp)1146 } else {1147 entry1148 }1149 }.toMutableList()1150 R(rollback, removed)1151 }1152 if (ready.removed > 0) persistOrRollback(ready.rollback)1153 ready.removed1154 }11551156 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1157 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1158 fun getDeletedEntries(): List<VaultEntry> =1159 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }11601161 1162 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1163 val active = entryList.filter { it.deletedAt == null }1164 if (tagFilter == null) return active1165 val needle = tagFilter.trim().lowercase()1166 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1167 }11681169 1170 fun getDistinctTags(): List<String> =1171 entryList.asSequence()1172 .filter { it.deletedAt == null }1173 .flatMap { it.tags?.asSequence() ?: emptySequence() }1174 .map { it.trim().lowercase() }1175 .filter { it.isNotEmpty() }1176 .distinct()1177 .sorted()1178 .toList()11791180 118111821183118411851186 fun getDistinctFieldValues(field: String): List<String> {1187 val select: (VaultEntry) -> String? = when (field) {1188 "username" -> { e -> e.username }1189 "email" -> { e -> e.email }1190 else -> return emptyList()1191 }1192 return entryList.asSequence()1193 .filter { it.deletedAt == null }1194 .mapNotNull { select(it)?.trim() }1195 .filter { it.isNotEmpty() }1196 .groupingBy { it.lowercase() }1197 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1198 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1199 }1200 .values1201 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1202 .map { it.first }1203 }12041205 fun getTombstones(): List<Tombstone> =1206 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1207 fun getVaultVersion(): Long = store.version()1208 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots12091210 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1211 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1212 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1213 val priorA = store.loadSlotA()1214 val priorB = store.loadSlotB()1215 1216 1217 1218 1219 1220 val priorHistory = store.loadVaultHistory()1221 1222 1223 1224 1225 if (!takeVaultSnapshot("restore")) {1226 return false1227 }1228 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1229 store.writeVaultHistory(priorHistory)1230 return false1231 }1232 lock()1233 true1234 }12351236 12371238123912401241124212431244 private fun commitRestoreAtomic(1245 nextA: StoredVault,1246 nextB: String,1247 priorA: StoredVault?,1248 priorB: String?,1249 ): Boolean {1250 if (!store.writeSlotA(nextA)) return false1251 1252 1253 1254 try {1255 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1256 store.stampLocalMutation()1257 } catch (_: Exception) {1258 rollbackRestore(priorA, priorB)1259 return false1260 }1261 return true1262 }12631264 12651266 12671268126912701271 suspend fun applyMergedRemoteEntries(1272 remoteEntries: List<VaultEntry>,1273 remoteTombstones: List<Tombstone>,1274 ): Int = mutex.withLock {1275 requireUnlocked()1276 1277 1278 1279 1280 1281 val mergeNow = System.currentTimeMillis()1282 val clamped = remoteEntries.map { entry ->1283 entry.copy(1284 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1285 deletedAt = entry.deletedAt?.let {1286 SyncLimits.clampSyncTimestamp(it, mergeNow)1287 },1288 )1289 }1290 val tombMap = remoteTombstones.associate {1291 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1292 }1293 1294 1295 val applied = mutateState {1296 var n = 01297 val byId = entryList.associateBy { it.id }.toMutableMap()12981299 for (remote in clamped) {1300 val tomb = tombMap[remote.id]1301 if (tomb != null && tomb >= remote.modifiedAt) continue1302 val local = byId[remote.id]1303 if (local == null) {1304 entryList.add(remote); byId[remote.id] = remote; n++1305 } else if (remote.modifiedAt > local.modifiedAt) {1306 val i = entryList.indexOfFirst { it.id == local.id }1307 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; n++ }1308 }1309 }13101311 for ((id, deletedAt) in tombMap) {1312 val local = byId[id] ?: continue1313 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1314 if (deletedAt < local.modifiedAt) continue1315 val i = entryList.indexOfFirst { it.id == id }1316 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); n++ }1317 }1318 n1319 }13201321 if (applied > 0) {1322 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1323 mutateState { publish() }1324 }1325 applied1326 }13271328 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1329 1330 1331 1332 mutateState { _settings.value = settings }1333 store.writeSettings(settings)1334 }13351336 13371338 1339 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet13401341 13421343134413451346 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1347 requireUnlocked()1348 1349 1350 mutateState {1351 recoveryWordHash = recoveryWordsHash(words)1352 recoveryWordsSet = true1353 }1354 1355 1356 1357 1358 runCatching {1359 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1360 }.getOrDefault(false)1361 }13621363 1364 fun verifyRecoveryWords(words: List<String>): Boolean {1365 val stored = recoveryWordHash ?: return false1366 return stored == recoveryWordsHash(words)1367 }13681369 13701371 1372 fun hasRecoveryEscrow(): Boolean =1373 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false13741375 137613771378137913801381138213831384138513861387 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1388 val slotA = store.loadSlotA() ?: return false1389 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false13901391 1392 1393 slotA.recoveryEscrow?.let { escrow ->1394 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1395 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1396 val keyBytes = recoveryKey.encoded1397 try {1398 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1399 val payload = VaultCodec.decode(plaintext)1400 1401 mutateState {1402 decoyMode = false1403 entryList = payload.entries.toMutableList()1404 duressConfigured = payload.duressConfigured1405 recoveryWordHash = payload.recoveryWordHash1406 recoveryWordsSet = payload.recoveryWordsSet1407 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1408 else generateRecoverableRootKey()1409 _settings.value = store.loadSettings()1410 }1411 VaultCrypto.zero(plaintext)1412 return true1413 } catch (_: Exception) {1414 1415 } finally {1416 VaultCrypto.zero(material)1417 VaultCrypto.zero(keyBytes)1418 }1419 }14201421 1422 1423 1424 slotA.rootKeyEscrow?.let { escrow ->1425 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1426 mutateState {1427 decoyMode = false1428 entryList = mutableListOf()1429 duressConfigured = false1430 recoveryWordHash = recoveryWordsHash(words)1431 recoveryWordsSet = true1432 rootKey = rk1433 _settings.value = store.loadSettings()1434 }1435 return true1436 }1437 return false1438 }14391440 144114421443144414451446144714481449 suspend fun resetPasswordWithRecoveryWords(1450 words: List<String>,1451 newPassword: String,1452 ): Boolean = mutex.withLock {1453 1454 1455 1456 if (!mutateState { rootKey != null }) return false1457 val priorA = store.loadSlotA() ?: return false1458 var newKey: ByteArray? = null1459 var plaintext: ByteArray? = null1460 return try {1461 val newSalt = VaultCrypto.generateSalt()1462 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1463 plaintext = VaultCodec.encode(currentPayload())1464 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1465 1466 1467 1468 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1469 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1470 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1471 val next = StoredVault(1472 version = priorA.version,1473 salt = b64(newSalt),1474 blob = blob,1475 recoveryEscrow = freshRecovery,1476 rootKeyEscrow = freshRootKey,1477 )1478 1479 1480 1481 val priorB = store.loadSlotB()1482 if (!commitVaultRotation(priorA, priorB, next)) return false1483 1484 1485 1486 mutateState {1487 derivedKey?.let(VaultCrypto::zero)1488 derivedKey = newKey; newKey = null1489 publish()1490 _isUnlocked.value = true1491 }1492 true1493 } catch (_: Exception) {1494 false1495 } finally {1496 newKey?.let(VaultCrypto::zero)1497 plaintext?.let(VaultCrypto::zero)1498 }1499 }15001501 150215031504150515061507 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1508 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1509 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1510 val salt = VaultCrypto.generateSalt()1511 val recoveryKey = deriveRecoveryKey(material, salt)1512 val keyBytes = recoveryKey.encoded1513 val plaintext = VaultCodec.encode(currentPayload())1514 return try {1515 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1516 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1517 } finally {1518 VaultCrypto.zero(material)1519 VaultCrypto.zero(keyBytes)1520 VaultCrypto.zero(plaintext)1521 }1522 }15231524 15251526152715281529153015311532 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1533 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1534 val rk = rootKey ?: return null1535 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1536 val salt = VaultCrypto.generateSalt()1537 val recoveryKey = deriveRecoveryKey(material, salt)1538 val keyBytes = recoveryKey.encoded1539 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1540 return try {1541 1542 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1543 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1544 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1545 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1546 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1547 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1548 } finally {1549 VaultCrypto.zero(material)1550 VaultCrypto.zero(keyBytes)1551 VaultCrypto.zero(plaintext)1552 }1553 }15541555 1556 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1557 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1558 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1559 val keyBytes = recoveryKey.encoded1560 return try {1561 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1562 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1563 hexToRootKey(String(pt, Charsets.US_ASCII))1564 } catch (_: Exception) {1565 null1566 } finally {1567 VaultCrypto.zero(material)1568 VaultCrypto.zero(keyBytes)1569 }1570 }15711572 157315741575157615771578 private fun persistSlotAWithEscrows(1579 recoveryEscrow: RecoveryEscrow?,1580 rootKeyEscrow: RootKeyEscrow?,1581 ): Boolean {1582 val key = derivedKey ?: throw IllegalStateException("vault_locked")1583 if (decoyMode) return false1584 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1585 val plaintext = VaultCodec.encode(currentPayload())1586 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1587 val next = prior.copy(1588 blob = blob,1589 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1590 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1591 )1592 if (!store.writeSlotA(next)) return false1593 try {1594 writeMatchingNoiseSlotB(next)1595 } catch (e: Exception) {1596 store.writeSlotA(prior)1597 throw e1598 }1599 return true1600 }16011602 16031604 private fun requireUnlocked() {1605 1606 1607 1608 stateLock.lock()1609 try {1610 if (derivedKey == null) throw IllegalStateException("vault_locked")1611 } finally {1612 stateLock.unlock()1613 }1614 }16151616 private fun countDuplicateEntriesLocked(): Int {1617 val seen = mutableMapOf<String, VaultEntry>()1618 var duplicates = 01619 for (entry in entryList.filter { it.deletedAt == null }) {1620 val key = entry.duplicateKey()1621 val existing = seen[key]1622 if (existing == null) {1623 seen[key] = entry1624 } else {1625 duplicates++1626 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1627 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1628 if (entryStamp > existingStamp) seen[key] = entry1629 }1630 }1631 return duplicates1632 }16331634 private fun VaultEntry.duplicateKey(): String {1635 if (type == "note") return "note|${title.lowercase()}"1636 val passkeyData = passkey1637 if (passkeyData != null) {1638 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1639 }1640 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1641 }16421643 164416451646164716481649 private fun takeVaultSnapshot(reason: String): Boolean {1650 val slotA = store.loadSlotA() ?: return false1651 val vaultA = VaultCodec.encodeSlot(slotA)1652 val vaultB = store.loadSlotB() ?: slotA.blob1653 val history = store.loadVaultHistory()1654 val latest = history.snapshots.firstOrNull()1655 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1656 val entryCount = if (derivedKey != null && !decoyMode) {1657 bucketEntryCount(entryList.count { it.deletedAt == null })1658 } else {1659 null1660 }1661 return try {1662 val snapshot = VaultSnapshot(1663 id = UUID.randomUUID().toString(),1664 savedAt = System.currentTimeMillis(),1665 reason = reason,1666 vault_a = vaultA,1667 vault_b = vaultB,1668 entryCount = entryCount,1669 )1670 1671 1672 1673 store.writeVaultHistory(1674 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1675 )1676 } catch (_: Exception) {1677 false1678 }1679 }16801681 private fun bucketEntryCount(n: Int): Int = when {1682 n <= 0 -> 01683 n <= 10 -> 101684 n <= 25 -> 251685 n <= 50 -> 501686 n <= 100 -> 1001687 n <= 250 -> 2501688 n <= 500 -> 5001689 else -> ((n + 499) / 500) * 5001690 }16911692 16931694169516961697 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1698 if (!entry.isCard()) return entry1699 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1700 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1701 }17021703 170417051706170717081709 private fun autoPurgeExpiredTrash() {1710 val days = _settings.value.trashRetentionDays ?: return1711 if (days <= 0) return1712 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1713 val before = entryList.size1714 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1715 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1716 }17171718 1719 private fun currentPayload(): VaultPayload =1720 VaultPayload(1721 entries = entryList.toList(),1722 duressConfigured = duressConfigured,1723 rootKeyB64 = rootKey?.let(::b64) ?: "",1724 recoveryWordHash = recoveryWordHash,1725 recoveryWordsSet = recoveryWordsSet,1726 )17271728 17291730173117321733 private fun ensureRootKey(payload: VaultPayload) {1734 if (payload.rootKeyB64.isNotBlank()) {1735 rootKey = unb64(payload.rootKeyB64)1736 return1737 }1738 rootKey = generateRecoverableRootKey()1739 runCatching { persistSlotA(bumpVersion = false) }1740 }17411742 private fun publish() {1743 _entries.value = entryList.filter { it.deletedAt == null }1744 }17451746 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1747 val plaintext = VaultCodec.encode(currentPayload())1748 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1749 return StoredVault(version = 1, salt = b64(salt), blob = blob)1750 }17511752 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1753 1754 1755 1756 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1757 val noise = ByteArray(approxBytes).also(rng::nextBytes)1758 return store.writeSlotB(b64(noise))1759 }17601761 1762 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1763 val key = derivedKey ?: throw IllegalStateException("vault_locked")1764 1765 if (decoyMode) {1766 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1767 val plaintext = VaultCodec.encode(currentPayload())1768 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1769 return store.writeDecoySlot(prior.copy(blob = blob))1770 }1771 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1772 1773 1774 1775 1776 val priorB = store.loadSlotB()1777 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1778 val plaintext = VaultCodec.encode(currentPayload())1779 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1780 val next = prior.copy(blob = blob)1781 if (!store.writeSlotA(next)) return false1782 1783 1784 1785 1786 try {1787 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1788 if (bumpVersion) store.stampLocalMutation()1789 } catch (e: Exception) {1790 store.writeSlotA(prior)1791 if (priorB != null) runCatching { store.writeSlotB(priorB) }1792 throw e1793 }1794 return true1795 }17961797 1798179918001801180218031804 private fun commitVaultRotation(1805 priorA: StoredVault,1806 priorB: String?,1807 nextA: StoredVault,1808 ): Boolean {1809 if (!store.writeSlotA(nextA)) return false1810 1811 1812 1813 try {1814 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1815 store.stampLocalMutation()1816 } catch (_: Exception) {1817 rollbackRotation(priorA, priorB)1818 return false1819 }1820 return true1821 }18221823 18241825182618271828 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1829 val failures = mutableListOf<String>()1830 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1831 .onFailure { failures += "writeSlotA: $it" }1832 if (priorB != null) {1833 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1834 .onFailure { failures += "writeSlotB: $it" }1835 }1836 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1837 }18381839 1840 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1841 try {1842 if (!persistSlotA(bumpVersion = true)) {1843 mutateState { entryList = rollback }1844 throw IllegalStateException("storage_write_failed")1845 }1846 } catch (e: Exception) {1847 mutateState { entryList = rollback }1848 throw e1849 }1850 mutateState { publish() }1851 }1852}