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.util.Base6410import java.util.UUID11import javax.inject.Inject12import javax.inject.Singleton1314private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)15private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)161718private const val PASSWORD_HISTORY_MAX = 50192021private const val DECOY_SENTINEL = "peach_decoy_v1"2223sealed interface UnlockResult {24 data object Success : UnlockResult25 data object WrongPassword : UnlockResult26 data class Error(val reason: String) : UnlockResult27}2829sealed interface CreateVaultResult {30 data object Success : CreateVaultResult31 data class Error(val reason: String) : CreateVaultResult32}333435363738394041424344454647@Singleton48class VaultRepository @Inject constructor(49 private val store: VaultStorage,50) {51 private val mutex = Mutex()52 private val rng = SecureRandom()5354 private var derivedKey: ByteArray? = null55 private var rootKey: ByteArray? = null56 private var entryList: MutableList<VaultEntry> = mutableListOf()57 private var duressConfigured: Boolean = false58 private var recoveryWordHash: String? = null59 private var recoveryWordsSet: Boolean = false60 61 private var decoyMode: Boolean = false6263 private val _isUnlocked = MutableStateFlow(false)64 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6566 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())67 68 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6970 private val _settings = MutableStateFlow(VaultSettings())71 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7273 fun hasVault(): Boolean = store.hasVault()7475 7677 7879808182 fun codexVaultBlobBase64(): String? {83 val slotA = store.loadSlotA() ?: return null84 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""85 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))86 }8788 8990919293 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {94 return try {95 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)96 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)97 ?: return UnlockResult.Error("invalid_codex_blob")98 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")99 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")100 writeMatchingNoiseSlotB(slotA)101 store.stampLocalMutation()102 unlockLocked(password)103 } catch (e: Exception) {104 UnlockResult.Error(e.message ?: "restore_failed")105 }106 }107108 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {109 return try {110 val salt = VaultCrypto.generateSalt()111 val key = VaultCrypto.deriveKey(password, salt)112 entryList = mutableListOf()113 duressConfigured = false114 recoveryWordHash = null115 recoveryWordsSet = false116 decoyMode = false117 rootKey = generateRecoverableRootKey()118 val slotA = sealSlotA(key, salt)119 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")120 writeMatchingNoiseSlotB(slotA)121 store.stampLocalMutation()122 store.writeSettings(_settings.value)123 derivedKey = key124 publish()125 _isUnlocked.value = true126 CreateVaultResult.Success127 } catch (e: Exception) {128 CreateVaultResult.Error(e.message ?: "create_failed")129 }130 }131132 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }133134 135 private fun unlockLocked(password: String): UnlockResult {136 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")137 return try {138 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))139 val payload = try {140 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))141 } catch (_: Exception) {142 VaultCrypto.zero(key)143 return UnlockResult.WrongPassword144 }145 derivedKey = key146 decoyMode = false147 entryList = payload.entries.toMutableList()148 duressConfigured = payload.duressConfigured149 recoveryWordHash = payload.recoveryWordHash150 recoveryWordsSet = payload.recoveryWordsSet151 _settings.value = store.loadSettings()152 ensureRootKey(payload)153 autoPurgeExpiredTrash()154 publish()155 _isUnlocked.value = true156 UnlockResult.Success157 } catch (e: Exception) {158 UnlockResult.Error(e.message ?: "unlock_failed")159 }160 }161162 163164165166167168 suspend fun unlockDecoy(): UnlockResult = mutex.withLock {169 return try {170 val existing = store.loadDecoySlot()171 if (existing == null) {172 val salt = VaultCrypto.generateSalt()173 val key = VaultCrypto.deriveKey(DECOY_SENTINEL, salt)174 entryList = mutableListOf()175 duressConfigured = false176 recoveryWordHash = null177 recoveryWordsSet = false178 rootKey = VaultCrypto.generateRootKey()179 derivedKey = key180 decoyMode = true181 val plaintext = VaultCodec.encode(currentPayload())182 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))183 if (!store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob)))184 return UnlockResult.Error("storage_write_failed")185 } else {186 val key = VaultCrypto.deriveKey(DECOY_SENTINEL, unb64(existing.salt))187 val payload = VaultCodec.decode(188 VaultCrypto.decrypt(unb64(existing.blob), key, VaultCrypto.AAD_SLOT_A),189 )190 derivedKey = key191 decoyMode = true192 entryList = payload.entries.toMutableList()193 duressConfigured = false194 recoveryWordHash = payload.recoveryWordHash195 recoveryWordsSet = payload.recoveryWordsSet196 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)197 else VaultCrypto.generateRootKey()198 }199 _settings.value = store.loadSettings()200 publish()201 _isUnlocked.value = true202 UnlockResult.Success203 } catch (e: Exception) {204 UnlockResult.Error(e.message ?: "decoy_unlock_failed")205 }206 }207208 209 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {210 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")211 return try {212 val payload = VaultCodec.decode(213 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),214 )215 derivedKey = key.copyOf()216 decoyMode = false217 entryList = payload.entries.toMutableList()218 duressConfigured = payload.duressConfigured219 recoveryWordHash = payload.recoveryWordHash220 recoveryWordsSet = payload.recoveryWordsSet221 _settings.value = store.loadSettings()222 ensureRootKey(payload)223 autoPurgeExpiredTrash()224 publish()225 _isUnlocked.value = true226 UnlockResult.Success227 } catch (_: Exception) {228 UnlockResult.WrongPassword229 }230 }231232 fun lock() {233 derivedKey?.let(VaultCrypto::zero)234 rootKey?.let(VaultCrypto::zero)235 derivedKey = null236 rootKey = null237 entryList = mutableListOf()238 duressConfigured = false239 recoveryWordHash = null240 recoveryWordsSet = false241 decoyMode = false242 _entries.value = emptyList()243 _isUnlocked.value = false244 }245246 247248 249250251252253 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()254255 256 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }257258 259260261262263 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()264265 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {266 val slotA = store.loadSlotA() ?: return false267 return try {268 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))269 val payload = VaultCodec.decode(270 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),271 )272 val newSalt = VaultCrypto.generateSalt()273 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)274 275 val plaintext = VaultCodec.encode(payload)276 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))277 278 279 280 281 282 283 284 val next = StoredVault(285 version = slotA.version,286 salt = b64(newSalt),287 blob = blob,288 recoveryEscrow = slotA.recoveryEscrow,289 rootKeyEscrow = slotA.rootKeyEscrow,290 )291 if (!store.writeSlotA(next)) return false292 writeMatchingNoiseSlotB(next)293 store.stampLocalMutation()294 VaultCrypto.zero(oldKey)295 derivedKey?.let(VaultCrypto::zero)296 derivedKey = newKey297 true298 } catch (_: Exception) {299 false300 }301 }302303 304305306307 suspend fun destroyVault() = mutex.withLock {308 lock()309 store.clearVault()310 store.clearDecoySlot()311 }312313 314315 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {316 requireUnlocked()317 val stamp = VaultCrypto.monotonicEntryTimestamp()318 val now = System.currentTimeMillis()319 val entry = draft.copy(320 id = draft.id.ifBlank { UUID.randomUUID().toString() },321 createdAt = if (draft.createdAt > 0) draft.createdAt else now,322 modifiedAt = stamp,323 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,324 )325 val rollback = entryList.toMutableList()326 entryList.add(entry)327 persistOrRollback(rollback)328 entry329 }330331 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {332 requireUnlocked()333 val idx = entryList.indexOfFirst { it.id == patch.id }334 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")335 val current = entryList[idx]336 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)337 338 val passwordChanged = patch.password != null && patch.password != current.password339 340 341 342 343 344 345 val priorPassword = current.password346 val shouldCapture = passwordChanged &&347 !priorPassword.isNullOrEmpty() &&348 current.passwordHistory?.firstOrNull()?.password != priorPassword349 val nextHistory = if (shouldCapture) {350 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +351 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)352 } else {353 current.passwordHistory354 }355 val updated = patch.copy(356 createdAt = current.createdAt,357 modifiedAt = stamp,358 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,359 passwordHistory = nextHistory,360 )361 val rollback = entryList.toMutableList()362 entryList[idx] = updated363 persistOrRollback(rollback)364 updated365 }366367 368 suspend fun deleteEntry(id: String) = mutex.withLock {369 requireUnlocked()370 val idx = entryList.indexOfFirst { it.id == id }371 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")372 val current = entryList[idx]373 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)374 val rollback = entryList.toMutableList()375 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)376 persistOrRollback(rollback)377 }378379 suspend fun restoreEntry(id: String) = mutex.withLock {380 requireUnlocked()381 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }382 if (idx == -1) throw IllegalStateException("deleted entry not found")383 val current = entryList[idx]384 val rollback = entryList.toMutableList()385 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))386 persistOrRollback(rollback)387 }388389 390 suspend fun purgeEntry(id: String) = mutex.withLock {391 requireUnlocked()392 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }393 if (idx == -1) throw IllegalStateException("deleted entry not found")394 val rollback = entryList.toMutableList()395 entryList.removeAt(idx)396 persistOrRollback(rollback)397 }398399 400 suspend fun purgeDeletedEntries(): Int = mutex.withLock {401 requireUnlocked()402 val rollback = entryList.toMutableList()403 val before = entryList.size404 entryList.removeAll { it.deletedAt != null }405 val removed = before - entryList.size406 if (removed > 0) persistOrRollback(rollback)407 removed408 }409410 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }411 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }412 fun getDeletedEntries(): List<VaultEntry> =413 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }414415 416 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {417 val active = entryList.filter { it.deletedAt == null }418 if (tagFilter == null) return active419 val needle = tagFilter.trim().lowercase()420 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }421 }422423 424 fun getDistinctTags(): List<String> =425 entryList.asSequence()426 .filter { it.deletedAt == null }427 .flatMap { it.tags?.asSequence() ?: emptySequence() }428 .map { it.trim().lowercase() }429 .filter { it.isNotEmpty() }430 .distinct()431 .sorted()432 .toList()433434 435436437438439440 fun getDistinctFieldValues(field: String): List<String> {441 val select: (VaultEntry) -> String? = when (field) {442 "username" -> { e -> e.username }443 "email" -> { e -> e.email }444 else -> return emptyList()445 }446 return entryList.asSequence()447 .filter { it.deletedAt == null }448 .mapNotNull { select(it)?.trim() }449 .filter { it.isNotEmpty() }450 .groupingBy { it.lowercase() }451 .aggregate { _, acc: Pair<String, Int>?, value, _ ->452 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)453 }454 .values455 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })456 .map { it.first }457 }458459 fun getTombstones(): List<Tombstone> =460 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }461 fun getVaultVersion(): Long = store.version()462463 464465 466467468469470 suspend fun applyMergedRemoteEntries(471 remoteEntries: List<VaultEntry>,472 remoteTombstones: List<Tombstone>,473 ): Int = mutex.withLock {474 requireUnlocked()475 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }476 var applied = 0477 val byId = entryList.associateBy { it.id }.toMutableMap()478479 for (remote in remoteEntries) {480 val tomb = tombMap[remote.id]481 if (tomb != null && tomb >= remote.modifiedAt) continue482 val local = byId[remote.id]483 if (local == null) {484 entryList.add(remote); byId[remote.id] = remote; applied++485 } else if (remote.modifiedAt > local.modifiedAt) {486 val i = entryList.indexOfFirst { it.id == local.id }487 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }488 }489 }490491 for ((id, deletedAt) in tombMap) {492 val local = byId[id] ?: continue493 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue494 if (deletedAt < local.modifiedAt) continue495 val i = entryList.indexOfFirst { it.id == id }496 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }497 }498499 if (applied > 0) {500 persistSlotA(bumpVersion = false)501 publish()502 }503 applied504 }505506 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {507 _settings.value = settings508 store.writeSettings(settings)509 }510511 512513 514 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet515516 517518519520521 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {522 requireUnlocked()523 recoveryWordHash = recoveryWordsHash(words)524 recoveryWordsSet = true525 526 527 528 529 runCatching {530 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))531 }.getOrDefault(false)532 }533534 535 fun verifyRecoveryWords(words: List<String>): Boolean {536 val stored = recoveryWordHash ?: return false537 return stored == recoveryWordsHash(words)538 }539540 541542 543 fun hasRecoveryEscrow(): Boolean =544 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false545546 547548549550551552553554555556557558 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {559 val slotA = store.loadSlotA() ?: return false560 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false561562 563 564 slotA.recoveryEscrow?.let { escrow ->565 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let566 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))567 val keyBytes = recoveryKey.encoded568 try {569 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)570 val payload = VaultCodec.decode(plaintext)571 decoyMode = false572 entryList = payload.entries.toMutableList()573 duressConfigured = payload.duressConfigured574 recoveryWordHash = payload.recoveryWordHash575 recoveryWordsSet = payload.recoveryWordsSet576 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)577 else generateRecoverableRootKey()578 _settings.value = store.loadSettings()579 VaultCrypto.zero(plaintext)580 return true581 } catch (_: Exception) {582 583 } finally {584 VaultCrypto.zero(material)585 VaultCrypto.zero(keyBytes)586 }587 }588589 590 591 592 slotA.rootKeyEscrow?.let { escrow ->593 val rk = unsealRootKeyEscrow(words, escrow) ?: return false594 decoyMode = false595 entryList = mutableListOf()596 duressConfigured = false597 recoveryWordHash = recoveryWordsHash(words)598 recoveryWordsSet = true599 rootKey = rk600 _settings.value = store.loadSettings()601 return true602 }603 return false604 }605606 607608609610611612613614615 suspend fun resetPasswordWithRecoveryWords(616 words: List<String>,617 newPassword: String,618 ): Boolean = mutex.withLock {619 if (rootKey == null) return false 620 return try {621 val newSalt = VaultCrypto.generateSalt()622 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)623 val plaintext = VaultCodec.encode(currentPayload())624 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))625 val prior = store.loadSlotA()626 627 628 629 val valid = words.size == 12 && findInvalidWords(words).isEmpty()630 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow631 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow632 val next = StoredVault(633 version = prior?.version ?: 1,634 salt = b64(newSalt),635 blob = blob,636 recoveryEscrow = freshRecovery,637 rootKeyEscrow = freshRootKey,638 )639 if (!store.writeSlotA(next)) return false640 writeMatchingNoiseSlotB(next)641 store.stampLocalMutation()642 derivedKey?.let(VaultCrypto::zero)643 derivedKey = newKey644 VaultCrypto.zero(plaintext)645 publish()646 _isUnlocked.value = true647 true648 } catch (_: Exception) {649 false650 }651 }652653 654655656657658659 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {660 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null661 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null662 val salt = VaultCrypto.generateSalt()663 val recoveryKey = deriveRecoveryKey(material, salt)664 val keyBytes = recoveryKey.encoded665 val plaintext = VaultCodec.encode(currentPayload())666 return try {667 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)668 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))669 } finally {670 VaultCrypto.zero(material)671 VaultCrypto.zero(keyBytes)672 VaultCrypto.zero(plaintext)673 }674 }675676 677678679680681682683684 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {685 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null686 val rk = rootKey ?: return null687 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null688 val salt = VaultCrypto.generateSalt()689 val recoveryKey = deriveRecoveryKey(material, salt)690 val keyBytes = recoveryKey.encoded691 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)692 return try {693 694 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)695 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)696 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)697 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)698 val ct = ctTag.copyOfRange(0, ctTag.size - 16)699 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))700 } finally {701 VaultCrypto.zero(material)702 VaultCrypto.zero(keyBytes)703 VaultCrypto.zero(plaintext)704 }705 }706707 708 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {709 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null710 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))711 val keyBytes = recoveryKey.encoded712 return try {713 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)714 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)715 hexToRootKey(String(pt, Charsets.US_ASCII))716 } catch (_: Exception) {717 null718 } finally {719 VaultCrypto.zero(material)720 VaultCrypto.zero(keyBytes)721 }722 }723724 725726727728729730 private fun persistSlotAWithEscrows(731 recoveryEscrow: RecoveryEscrow?,732 rootKeyEscrow: RootKeyEscrow?,733 ): Boolean {734 val key = derivedKey ?: throw IllegalStateException("vault_locked")735 if (decoyMode) return false736 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")737 val plaintext = VaultCodec.encode(currentPayload())738 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))739 val next = prior.copy(740 blob = blob,741 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,742 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,743 )744 if (!store.writeSlotA(next)) return false745 try {746 writeMatchingNoiseSlotB(next)747 } catch (e: Exception) {748 store.writeSlotA(prior)749 throw e750 }751 return true752 }753754 755756 private fun requireUnlocked() {757 if (derivedKey == null) throw IllegalStateException("vault_locked")758 }759760 761762763764765766 private fun autoPurgeExpiredTrash() {767 val days = _settings.value.trashRetentionDays ?: return768 if (days <= 0) return769 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L770 val before = entryList.size771 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }772 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }773 }774775 776 private fun currentPayload(): VaultPayload =777 VaultPayload(778 entries = entryList.toList(),779 duressConfigured = duressConfigured,780 rootKeyB64 = rootKey?.let(::b64) ?: "",781 recoveryWordHash = recoveryWordHash,782 recoveryWordsSet = recoveryWordsSet,783 )784785 786787788789790 private fun ensureRootKey(payload: VaultPayload) {791 if (payload.rootKeyB64.isNotBlank()) {792 rootKey = unb64(payload.rootKeyB64)793 return794 }795 rootKey = generateRecoverableRootKey()796 runCatching { persistSlotA(bumpVersion = false) }797 }798799 private fun publish() {800 _entries.value = entryList.filter { it.deletedAt == null }801 }802803 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {804 val plaintext = VaultCodec.encode(currentPayload())805 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))806 return StoredVault(version = 1, salt = b64(salt), blob = blob)807 }808809 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {810 val approxBytes = (slotA.blob.length / 2).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)811 val noise = ByteArray(approxBytes).also(rng::nextBytes)812 store.writeSlotB(b64(noise))813 }814815 816 private fun persistSlotA(bumpVersion: Boolean): Boolean {817 val key = derivedKey ?: throw IllegalStateException("vault_locked")818 819 if (decoyMode) {820 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")821 val plaintext = VaultCodec.encode(currentPayload())822 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))823 return store.writeDecoySlot(prior.copy(blob = blob))824 }825 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")826 val plaintext = VaultCodec.encode(currentPayload())827 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))828 val next = prior.copy(blob = blob)829 if (!store.writeSlotA(next)) return false830 831 832 try {833 writeMatchingNoiseSlotB(next)834 if (bumpVersion) store.stampLocalMutation()835 } catch (e: Exception) {836 store.writeSlotA(prior)837 throw e838 }839 return true840 }841842 843 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {844 try {845 if (!persistSlotA(bumpVersion = true)) {846 entryList = rollback847 throw IllegalStateException("storage_write_failed")848 }849 } catch (e: Exception) {850 entryList = rollback851 throw e852 }853 publish()854 }855}