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 javax.inject.Inject21import javax.inject.Singleton222324private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2526private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)27private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)282930private const val PASSWORD_HISTORY_MAX = 5031private const val VAULT_HISTORY_MAX = 53233sealed interface UnlockResult {34 data object Success : UnlockResult35 data object WrongPassword : UnlockResult36 data class Error(val reason: String) : UnlockResult37}3839sealed interface CreateVaultResult {40 data object Success : CreateVaultResult41 data class Error(val reason: String) : CreateVaultResult42}4344sealed interface AdoptVaultResult {45 data class Success(val vaultId: String) : AdoptVaultResult46 data class Error(val reason: String) : AdoptVaultResult47}484950515253545556575859606162@Singleton63class VaultRepository @Inject constructor(64 private val store: VaultStorage,65) {66 private val mutex = Mutex()67 private val rng = SecureRandom()6869 private var derivedKey: ByteArray? = null70 private var rootKey: ByteArray? = null71 private var entryList: MutableList<VaultEntry> = mutableListOf()72 private var duressConfigured: Boolean = false73 private var recoveryWordHash: String? = null74 private var recoveryWordsSet: Boolean = false75 76 private var decoyMode: Boolean = false7778 private val _isUnlocked = MutableStateFlow(false)79 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()8081 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())82 83 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8485 private val _settings = MutableStateFlow(VaultSettings())86 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8788 fun hasVault(): Boolean = store.hasVault()8990 9192 9394959697 fun codexVaultBlobBase64(): String? {98 val slotA = store.loadSlotA() ?: return null99 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""100 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))101 }102103 104105106107108 109 private fun JsonElement?.asSlotJson(): String? = when {110 this == null || this is JsonNull -> null111 this is JsonPrimitive && isString -> content112 else -> toString()113 }114115 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {116 var candidateKey: ByteArray? = null117 var plaintext: ByteArray? = null118 return try {119 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)120 121 122 123 124 125 126 127 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject128 ?: return UnlockResult.Error("invalid_codex_blob")129 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")130 131 132 133 134 135 val slotA = VaultCodec.decodeSlot(slotJson)136 ?: BrowserSlot.parse(slotJson)?.let {137 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword138 }139 ?: return UnlockResult.Error("invalid_codex_blob")140 141 142 143 144 145 candidateKey = VaultCrypto.deriveKey(password, unb64(slotA.salt))146 plaintext = decryptSlotABlob(slotA, candidateKey)147 148 149 150 151 if (plaintext == null && password != Normalizer.normalize(password, Normalizer.Form.NFC)) {152 VaultCrypto.zero(candidateKey)153 candidateKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))154 plaintext = decryptSlotABlob(slotA, candidateKey)155 }156 if (plaintext == null) return UnlockResult.WrongPassword157 val payload = try {158 VaultCodec.decode(plaintext)159 } catch (_: Exception) {160 return UnlockResult.Error("invalid_codex_blob")161 }162163 val priorA = store.loadSlotA()164 val priorB = store.loadSlotB()165 166 167 val codexSlotB = wrapper["b"].asSlotJson()168169 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")170 val committed = runCatching {171 val wroteB = if (codexSlotB != null) {172 store.writeSlotB(Base64.getEncoder().encodeToString(codexSlotB.toByteArray(Charsets.UTF_8)))173 } else {174 writeMatchingNoiseSlotB(slotA)175 }176 if (!wroteB) throw IllegalStateException("storage_write_failed")177 store.stampLocalMutation()178 }.isSuccess179 if (!committed) {180 rollbackRestore(priorA, priorB)181 return UnlockResult.Error("storage_write_failed")182 }183184 val ownedKey = candidateKey!!185 candidateKey = null 186 adoptSlotA(ownedKey, payload)187 } catch (e: Exception) {188 189 190 191 192 193 194 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))195 UnlockResult.Error(e.message ?: "restore_failed")196 } finally {197 plaintext?.let(VaultCrypto::zero)198 candidateKey?.let(VaultCrypto::zero)199 }200 }201202 203 private fun decryptSlotABlob(slot: StoredVault, key: ByteArray): ByteArray? =204 runCatching { VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A) }.getOrNull()205206 207208209210211212 private fun rollbackRestore(priorA: StoredVault?, priorB: String?) {213 val failures = mutableListOf<String>()214 if (priorA == null) {215 runCatching { store.clearVault() }.onFailure { failures += "clearVault: $it" }216 } else {217 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }218 .onFailure { failures += "writeSlotA: $it" }219 if (priorB != null) {220 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }221 .onFailure { failures += "writeSlotB: $it" }222 }223 }224 225 226 if (failures.isNotEmpty()) runCatching { Log.w("PeachCodex", "codex rollback incomplete: $failures") }227 }228229 230 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {231 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)232 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()233 val window = offset?.let {234 val from = (it - 40).coerceAtLeast(0)235 val to = (it + 40).coerceAtMost(wrapperJson.length)236 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")237 }238 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()239 val slot = wrapper?.get("a")240 buildString {241 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)242 append(" | blobBase64=").append(vaultBlobBase64.length)243 append(" wrapperChars=").append(wrapperJson.length)244 append(" wrapperParses=").append(wrapper != null)245 append(" keys=").append(wrapper?.keys)246 append(" slotAKind=").append(247 when {248 slot == null -> "absent"249 slot is JsonPrimitive && slot.isString -> "string"250 slot is JsonObject -> "object:" + slot.keys251 else -> slot::class.simpleName252 },253 )254 if (window != null) append(" masked@").append(offset).append("=").append(window)255 }256 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }257258 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {259 return try {260 val salt = VaultCrypto.generateSalt()261 val key = VaultCrypto.deriveKey(password, salt)262 entryList = mutableListOf()263 duressConfigured = false264 recoveryWordHash = null265 recoveryWordsSet = false266 decoyMode = false267 rootKey = generateRecoverableRootKey()268 val slotA = sealSlotA(key, salt)269 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")270 writeMatchingNoiseSlotB(slotA)271 store.stampLocalMutation()272 store.writeSettings(_settings.value)273 derivedKey = key274 publish()275 _isUnlocked.value = true276 CreateVaultResult.Success277 } catch (e: Exception) {278 CreateVaultResult.Error(e.message ?: "create_failed")279 }280 }281282 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {283 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")284 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")285 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {286 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")287 }288 var adoptedRootKey: ByteArray? = null289 var nextDerivedKey: ByteArray? = null290 var sessionRootKey: ByteArray? = null291 return try {292 adoptedRootKey = hexToRootKey(rootKeyHex)293 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {294 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")295 }296 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)297 val salt = VaultCrypto.generateSalt()298 nextDerivedKey = VaultCrypto.deriveKey(password, salt)299 sessionRootKey = adoptedRootKey.copyOf()300 val payload = VaultPayload(301 entries = emptyList(),302 duressConfigured = false,303 rootKeyB64 = b64(sessionRootKey),304 recoveryWordHash = null,305 recoveryWordsSet = false,306 )307 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))308 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)309 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")310 writeMatchingNoiseSlotB(slotA)311 store.stampLocalMutation()312 store.writeSettings(_settings.value)313314 val previousDerivedKey = derivedKey315 val previousRootKey = rootKey316 derivedKey = nextDerivedKey317 rootKey = sessionRootKey318 nextDerivedKey = null319 sessionRootKey = null320 entryList = mutableListOf()321 duressConfigured = false322 recoveryWordHash = null323 recoveryWordsSet = false324 decoyMode = false325 publish()326 _isUnlocked.value = true327 previousDerivedKey?.let(VaultCrypto::zero)328 previousRootKey?.let(VaultCrypto::zero)329 AdoptVaultResult.Success(vaultId)330 } catch (e: Exception) {331 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")332 } finally {333 adoptedRootKey?.let(VaultCrypto::zero)334 nextDerivedKey?.let(VaultCrypto::zero)335 sessionRootKey?.let(VaultCrypto::zero)336 }337 }338339 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }340341 342 private fun unlockLocked(password: String): UnlockResult {343 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")344 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))345 val payload = try {346 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))347 } catch (_: Exception) {348 VaultCrypto.zero(key)349 350 351 352 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {353 val legacy = tryLegacyRawUnlock(password, slotA)354 if (legacy != null) return legacy355 }356 return UnlockResult.WrongPassword357 }358 return adoptSlotA(key, payload)359 }360361 362363364365366367 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {368 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))369 val payload = try {370 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))371 } catch (_: Exception) {372 VaultCrypto.zero(rawKey)373 return null374 }375 376 377 val newSalt = VaultCrypto.generateSalt()378 val newKey = VaultCrypto.deriveKey(password, newSalt)379 val healed = try {380 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))381 val next = StoredVault(382 version = slotA.version,383 salt = b64(newSalt),384 blob = blob,385 recoveryEscrow = slotA.recoveryEscrow,386 rootKeyEscrow = slotA.rootKeyEscrow,387 )388 if (store.writeSlotA(next)) {389 writeMatchingNoiseSlotB(next)390 store.stampLocalMutation()391 true392 } else {393 false394 }395 } catch (_: Exception) {396 false397 }398 return if (healed) {399 VaultCrypto.zero(rawKey)400 adoptSlotA(newKey, payload) 401 } else {402 VaultCrypto.zero(newKey)403 adoptSlotA(rawKey, payload) 404 }405 }406407 408 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {409 return try {410 derivedKey = key411 decoyMode = false412 entryList = payload.entries.toMutableList()413 duressConfigured = payload.duressConfigured414 recoveryWordHash = payload.recoveryWordHash415 recoveryWordsSet = payload.recoveryWordsSet416 _settings.value = store.loadSettings()417 ensureRootKey(payload)418 autoPurgeExpiredTrash()419 publish()420 _isUnlocked.value = true421 UnlockResult.Success422 } catch (e: Exception) {423 UnlockResult.Error(e.message ?: "unlock_failed")424 }425 }426427 428429 430431432433434435 class SlotAUnlock internal constructor(436 internal val key: ByteArray,437 internal val payload: VaultPayload,438 )439440 441442443444445446 fun tryUnlockSlotA(password: String): SlotAUnlock? {447 val slotA = store.loadSlotA() ?: return null448 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))449 return try {450 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))451 SlotAUnlock(key, payload)452 } catch (_: Exception) {453 VaultCrypto.zero(key)454 null455 }456 }457458 459 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {460 adoptSlotA(unlock.key, unlock.payload)461 }462463 464 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)465466 467468 469470471472473 class DuressUnlock internal constructor(474 internal val key: ByteArray,475 internal val payload: VaultPayload,476 )477478 479480481482483484485 fun tryUnlockDuress(password: String): DuressUnlock? {486 val slot = store.loadDecoySlot() ?: return null487 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))488 return try {489 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))490 DuressUnlock(key, payload)491 } catch (_: Exception) {492 VaultCrypto.zero(key)493 null494 }495 }496497 498 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {499 return try {500 derivedKey = unlock.key501 decoyMode = true502 entryList = unlock.payload.entries.toMutableList()503 duressConfigured = false504 recoveryWordHash = unlock.payload.recoveryWordHash505 recoveryWordsSet = unlock.payload.recoveryWordsSet506 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)507 else VaultCrypto.generateRootKey()508 _settings.value = store.loadSettings()509 publish()510 _isUnlocked.value = true511 UnlockResult.Success512 } catch (e: Exception) {513 UnlockResult.Error(e.message ?: "decoy_unlock_failed")514 }515 }516517 518 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)519520 521522523524525526 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {527 if (derivedKey == null || decoyMode) return false528 529 530 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }531 return try {532 val salt = VaultCrypto.generateSalt()533 val key = VaultCrypto.deriveKey(duressPassphrase, salt)534 535 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))536 val plaintext = VaultCodec.encode(decoyPayload)537 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))538 VaultCrypto.zero(key)539 VaultCrypto.zero(plaintext)540 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))541 if (ok) {542 duressConfigured = true543 runCatching { persistSlotA(bumpVersion = false) }544 }545 ok546 } catch (_: Exception) {547 false548 }549 }550551 552 suspend fun clearDuress(): Boolean = mutex.withLock {553 store.clearDecoySlot()554 if (derivedKey != null && !decoyMode) {555 duressConfigured = false556 runCatching { persistSlotA(bumpVersion = false) }557 }558 true559 }560561 562 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {563 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")564 return try {565 val payload = VaultCodec.decode(566 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),567 )568 derivedKey = key.copyOf()569 decoyMode = false570 entryList = payload.entries.toMutableList()571 duressConfigured = payload.duressConfigured572 recoveryWordHash = payload.recoveryWordHash573 recoveryWordsSet = payload.recoveryWordsSet574 _settings.value = store.loadSettings()575 ensureRootKey(payload)576 autoPurgeExpiredTrash()577 publish()578 _isUnlocked.value = true579 UnlockResult.Success580 } catch (_: Exception) {581 UnlockResult.WrongPassword582 }583 }584585 fun lock() {586 derivedKey?.let(VaultCrypto::zero)587 rootKey?.let(VaultCrypto::zero)588 derivedKey = null589 rootKey = null590 entryList = mutableListOf()591 duressConfigured = false592 recoveryWordHash = null593 recoveryWordsSet = false594 decoyMode = false595 _entries.value = emptyList()596 _isUnlocked.value = false597 }598599 @Volatile private var suppressNextBackgroundLock = false600601 602603604605606607608609610 fun expectTransientBackground() {611 suppressNextBackgroundLock = true612 }613614 615 fun consumeTransientBackgroundSuppression(): Boolean {616 val suppressed = suppressNextBackgroundLock617 suppressNextBackgroundLock = false618 return suppressed619 }620621 622623 624625626627628 629630631632633634635636637638639 fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()640641 642643644645646647648649650 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()651652 653654 fun syncVaultId(): String? =655 if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }656657 658659660661662 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {663 installRootKey(generateRecoverableRootKey())664 }665666 667668669670671672673674675676677678679680681682683 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {684 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {685 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")686 }687 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")688 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")689 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")690691 val incoming = hexToRootKey(rootKeyHex)692 if (incoming.size != VaultCrypto.KEY_SIZE) {693 VaultCrypto.zero(incoming)694 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")695 }696 697 698 699 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess700 if (!roundTrips) {701 VaultCrypto.zero(incoming)702 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")703 }704705 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)706 return if (installRootKey(incoming)) {707 AdoptVaultResult.Success(vaultId)708 } else {709 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")710 }711 }712713 714715716717718 fun previewRecoveryWords(rootKeyHex: String): List<String>? {719 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null720 val bytes = hexToRootKey(rootKeyHex)721 return try {722 val words = rootKeyToWords(bytes)723 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null724 } catch (_: Exception) {725 null726 } finally {727 VaultCrypto.zero(bytes)728 }729 }730731 732733734735736 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {737 val key = derivedKey ?: return false738 if (decoyMode) return false739 val priorSlot = store.loadSlotA() ?: return false740 val priorRootKey = rootKey ?: return false741 val priorRecoveryHash = recoveryWordHash742 val priorRecoverySet = recoveryWordsSet743 val nextWords = rootKeyToWords(nextRootKey)744745 return try {746 takeVaultSnapshot("local_save")747 rootKey = nextRootKey748 recoveryWordHash = recoveryWordsHash(nextWords)749 recoveryWordsSet = false750751 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)752 ?: throw IllegalStateException("recovery_escrow_failed")753 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)754 ?: throw IllegalStateException("root_escrow_failed")755 val plaintext = VaultCodec.encode(currentPayload())756 val blob = try {757 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))758 } finally {759 VaultCrypto.zero(plaintext)760 }761 val nextSlot = priorSlot.copy(762 blob = blob,763 recoveryEscrow = nextRecoveryEscrow,764 rootKeyEscrow = nextRootKeyEscrow,765 )766 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")767 try {768 writeMatchingNoiseSlotB(nextSlot)769 store.stampLocalMutation()770 } catch (e: Exception) {771 store.writeSlotA(priorSlot)772 throw e773 }774 VaultCrypto.zero(priorRootKey)775 publish()776 true777 } catch (_: Exception) {778 rootKey = priorRootKey779 recoveryWordHash = priorRecoveryHash780 recoveryWordsSet = priorRecoverySet781 VaultCrypto.zero(nextRootKey)782 store.writeSlotA(priorSlot)783 false784 }785 }786787 788789790791792 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()793794 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {795 val slotA = store.loadSlotA() ?: return false796 return try {797 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))798 val payload = VaultCodec.decode(799 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),800 )801 val newSalt = VaultCrypto.generateSalt()802 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)803 804 val plaintext = VaultCodec.encode(payload)805 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))806 807 808 809 810 811 812 813 val next = StoredVault(814 version = slotA.version,815 salt = b64(newSalt),816 blob = blob,817 recoveryEscrow = slotA.recoveryEscrow,818 rootKeyEscrow = slotA.rootKeyEscrow,819 )820 if (!store.writeSlotA(next)) return false821 writeMatchingNoiseSlotB(next)822 store.stampLocalMutation()823 VaultCrypto.zero(oldKey)824 derivedKey?.let(VaultCrypto::zero)825 derivedKey = newKey826 true827 } catch (_: Exception) {828 false829 }830 }831832 833834835836 suspend fun destroyVault() = mutex.withLock {837 lock()838 store.clearVault()839 store.clearDecoySlot()840 }841842 843844 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {845 requireUnlocked()846 val stamp = VaultCrypto.monotonicEntryTimestamp()847 val now = System.currentTimeMillis()848 val entry = withDerivedLast4(draft.copy(849 id = draft.id.ifBlank { UUID.randomUUID().toString() },850 createdAt = if (draft.createdAt > 0) draft.createdAt else now,851 modifiedAt = stamp,852 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,853 ))854 val rollback = entryList.toMutableList()855 entryList.add(entry)856 persistOrRollback(rollback)857 entry858 }859860 861862863864865866867868869 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {870 requireUnlocked()871 if (drafts.isEmpty()) return 0872 val rollback = entryList.toMutableList()873 val now = System.currentTimeMillis()874 for (draft in drafts) {875 val stamp = VaultCrypto.monotonicEntryTimestamp()876 entryList.add(877 withDerivedLast4(878 draft.copy(879 id = draft.id.ifBlank { UUID.randomUUID().toString() },880 createdAt = if (draft.createdAt > 0) draft.createdAt else now,881 modifiedAt = stamp,882 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,883 ),884 ),885 )886 }887 persistOrRollback(rollback)888 return drafts.size889 }890891 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {892 requireUnlocked()893 val idx = entryList.indexOfFirst { it.id == patch.id }894 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")895 val current = entryList[idx]896 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)897 898 val passwordChanged = patch.password != null && patch.password != current.password899 900 901 902 903 904 905 val priorPassword = current.password906 val shouldCapture = passwordChanged &&907 !priorPassword.isNullOrEmpty() &&908 current.passwordHistory?.firstOrNull()?.password != priorPassword909 val nextHistory = if (shouldCapture) {910 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +911 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)912 } else {913 current.passwordHistory914 }915 val updated = withDerivedLast4(patch.copy(916 createdAt = current.createdAt,917 modifiedAt = stamp,918 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,919 passwordHistory = nextHistory,920 ))921 val rollback = entryList.toMutableList()922 entryList[idx] = updated923 persistOrRollback(rollback)924 updated925 }926927 928 suspend fun deleteEntry(id: String) = mutex.withLock {929 requireUnlocked()930 val idx = entryList.indexOfFirst { it.id == id }931 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")932 val current = entryList[idx]933 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)934 val rollback = entryList.toMutableList()935 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)936 persistOrRollback(rollback)937 }938939 suspend fun restoreEntry(id: String) = mutex.withLock {940 requireUnlocked()941 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }942 if (idx == -1) throw IllegalStateException("deleted entry not found")943 val current = entryList[idx]944 val rollback = entryList.toMutableList()945 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))946 persistOrRollback(rollback)947 }948949 950 suspend fun purgeEntry(id: String) = mutex.withLock {951 requireUnlocked()952 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }953 if (idx == -1) throw IllegalStateException("deleted entry not found")954 val rollback = entryList.toMutableList()955 entryList.removeAt(idx)956 persistOrRollback(rollback)957 }958959 960 suspend fun purgeDeletedEntries(): Int = mutex.withLock {961 requireUnlocked()962 val rollback = entryList.toMutableList()963 val before = entryList.size964 entryList.removeAll { it.deletedAt != null }965 val removed = before - entryList.size966 if (removed > 0) persistOrRollback(rollback)967 removed968 }969970 971 suspend fun countDuplicateEntries(): Int = mutex.withLock {972 requireUnlocked()973 countDuplicateEntriesLocked()974 }975976 977978979980981 suspend fun deduplicateEntries(): Int = mutex.withLock {982 requireUnlocked()983 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")984 val active = entryList.filter { it.deletedAt == null }985 val keepIds = mutableSetOf<String>()986 active.groupBy { it.duplicateKey() }.values.forEach { group ->987 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id988 }989990 val rollback = entryList.toMutableList()991 var removed = 0992 entryList = entryList.map { entry ->993 if (entry.deletedAt == null && entry.id !in keepIds) {994 removed++995 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)996 entry.copy(deletedAt = stamp, modifiedAt = stamp)997 } else {998 entry999 }1000 }.toMutableList()1001 if (removed > 0) persistOrRollback(rollback)1002 removed1003 }10041005 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1006 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1007 fun getDeletedEntries(): List<VaultEntry> =1008 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }10091010 1011 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1012 val active = entryList.filter { it.deletedAt == null }1013 if (tagFilter == null) return active1014 val needle = tagFilter.trim().lowercase()1015 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1016 }10171018 1019 fun getDistinctTags(): List<String> =1020 entryList.asSequence()1021 .filter { it.deletedAt == null }1022 .flatMap { it.tags?.asSequence() ?: emptySequence() }1023 .map { it.trim().lowercase() }1024 .filter { it.isNotEmpty() }1025 .distinct()1026 .sorted()1027 .toList()10281029 103010311032103310341035 fun getDistinctFieldValues(field: String): List<String> {1036 val select: (VaultEntry) -> String? = when (field) {1037 "username" -> { e -> e.username }1038 "email" -> { e -> e.email }1039 else -> return emptyList()1040 }1041 return entryList.asSequence()1042 .filter { it.deletedAt == null }1043 .mapNotNull { select(it)?.trim() }1044 .filter { it.isNotEmpty() }1045 .groupingBy { it.lowercase() }1046 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1047 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1048 }1049 .values1050 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1051 .map { it.first }1052 }10531054 fun getTombstones(): List<Tombstone> =1055 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1056 fun getVaultVersion(): Long = store.version()1057 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots10581059 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1060 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1061 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1062 takeVaultSnapshot("restore")1063 if (!store.writeSlotA(restoredA)) return false1064 if (!store.writeSlotB(snapshot.vault_b)) return false1065 store.stampLocalMutation()1066 lock()1067 true1068 }10691070 10711072 10731074107510761077 suspend fun applyMergedRemoteEntries(1078 remoteEntries: List<VaultEntry>,1079 remoteTombstones: List<Tombstone>,1080 ): Int = mutex.withLock {1081 requireUnlocked()1082 1083 1084 1085 1086 1087 val mergeNow = System.currentTimeMillis()1088 val clamped = remoteEntries.map { entry ->1089 entry.copy(1090 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1091 deletedAt = entry.deletedAt?.let {1092 SyncLimits.clampSyncTimestamp(it, mergeNow)1093 },1094 )1095 }1096 val tombMap = remoteTombstones.associate {1097 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1098 }1099 var applied = 01100 val byId = entryList.associateBy { it.id }.toMutableMap()11011102 for (remote in clamped) {1103 val tomb = tombMap[remote.id]1104 if (tomb != null && tomb >= remote.modifiedAt) continue1105 val local = byId[remote.id]1106 if (local == null) {1107 entryList.add(remote); byId[remote.id] = remote; applied++1108 } else if (remote.modifiedAt > local.modifiedAt) {1109 val i = entryList.indexOfFirst { it.id == local.id }1110 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1111 }1112 }11131114 for ((id, deletedAt) in tombMap) {1115 val local = byId[id] ?: continue1116 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1117 if (deletedAt < local.modifiedAt) continue1118 val i = entryList.indexOfFirst { it.id == id }1119 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1120 }11211122 if (applied > 0) {1123 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1124 publish()1125 }1126 applied1127 }11281129 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1130 _settings.value = settings1131 store.writeSettings(settings)1132 }11331134 11351136 1137 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet11381139 11401141114211431144 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1145 requireUnlocked()1146 recoveryWordHash = recoveryWordsHash(words)1147 recoveryWordsSet = true1148 1149 1150 1151 1152 runCatching {1153 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1154 }.getOrDefault(false)1155 }11561157 1158 fun verifyRecoveryWords(words: List<String>): Boolean {1159 val stored = recoveryWordHash ?: return false1160 return stored == recoveryWordsHash(words)1161 }11621163 11641165 1166 fun hasRecoveryEscrow(): Boolean =1167 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false11681169 117011711172117311741175117611771178117911801181 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1182 val slotA = store.loadSlotA() ?: return false1183 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false11841185 1186 1187 slotA.recoveryEscrow?.let { escrow ->1188 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1189 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1190 val keyBytes = recoveryKey.encoded1191 try {1192 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1193 val payload = VaultCodec.decode(plaintext)1194 decoyMode = false1195 entryList = payload.entries.toMutableList()1196 duressConfigured = payload.duressConfigured1197 recoveryWordHash = payload.recoveryWordHash1198 recoveryWordsSet = payload.recoveryWordsSet1199 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1200 else generateRecoverableRootKey()1201 _settings.value = store.loadSettings()1202 VaultCrypto.zero(plaintext)1203 return true1204 } catch (_: Exception) {1205 1206 } finally {1207 VaultCrypto.zero(material)1208 VaultCrypto.zero(keyBytes)1209 }1210 }12111212 1213 1214 1215 slotA.rootKeyEscrow?.let { escrow ->1216 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1217 decoyMode = false1218 entryList = mutableListOf()1219 duressConfigured = false1220 recoveryWordHash = recoveryWordsHash(words)1221 recoveryWordsSet = true1222 rootKey = rk1223 _settings.value = store.loadSettings()1224 return true1225 }1226 return false1227 }12281229 123012311232123312341235123612371238 suspend fun resetPasswordWithRecoveryWords(1239 words: List<String>,1240 newPassword: String,1241 ): Boolean = mutex.withLock {1242 if (rootKey == null) return false 1243 return try {1244 val newSalt = VaultCrypto.generateSalt()1245 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1246 val plaintext = VaultCodec.encode(currentPayload())1247 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1248 val prior = store.loadSlotA()1249 1250 1251 1252 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1253 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1254 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1255 val next = StoredVault(1256 version = prior?.version ?: 1,1257 salt = b64(newSalt),1258 blob = blob,1259 recoveryEscrow = freshRecovery,1260 rootKeyEscrow = freshRootKey,1261 )1262 if (!store.writeSlotA(next)) return false1263 writeMatchingNoiseSlotB(next)1264 store.stampLocalMutation()1265 derivedKey?.let(VaultCrypto::zero)1266 derivedKey = newKey1267 VaultCrypto.zero(plaintext)1268 publish()1269 _isUnlocked.value = true1270 true1271 } catch (_: Exception) {1272 false1273 }1274 }12751276 127712781279128012811282 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1283 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1284 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1285 val salt = VaultCrypto.generateSalt()1286 val recoveryKey = deriveRecoveryKey(material, salt)1287 val keyBytes = recoveryKey.encoded1288 val plaintext = VaultCodec.encode(currentPayload())1289 return try {1290 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1291 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1292 } finally {1293 VaultCrypto.zero(material)1294 VaultCrypto.zero(keyBytes)1295 VaultCrypto.zero(plaintext)1296 }1297 }12981299 13001301130213031304130513061307 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1308 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1309 val rk = rootKey ?: return null1310 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1311 val salt = VaultCrypto.generateSalt()1312 val recoveryKey = deriveRecoveryKey(material, salt)1313 val keyBytes = recoveryKey.encoded1314 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1315 return try {1316 1317 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1318 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1319 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1320 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1321 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1322 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1323 } finally {1324 VaultCrypto.zero(material)1325 VaultCrypto.zero(keyBytes)1326 VaultCrypto.zero(plaintext)1327 }1328 }13291330 1331 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1332 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1333 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1334 val keyBytes = recoveryKey.encoded1335 return try {1336 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1337 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1338 hexToRootKey(String(pt, Charsets.US_ASCII))1339 } catch (_: Exception) {1340 null1341 } finally {1342 VaultCrypto.zero(material)1343 VaultCrypto.zero(keyBytes)1344 }1345 }13461347 134813491350135113521353 private fun persistSlotAWithEscrows(1354 recoveryEscrow: RecoveryEscrow?,1355 rootKeyEscrow: RootKeyEscrow?,1356 ): Boolean {1357 val key = derivedKey ?: throw IllegalStateException("vault_locked")1358 if (decoyMode) return false1359 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1360 val plaintext = VaultCodec.encode(currentPayload())1361 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1362 val next = prior.copy(1363 blob = blob,1364 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1365 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1366 )1367 if (!store.writeSlotA(next)) return false1368 try {1369 writeMatchingNoiseSlotB(next)1370 } catch (e: Exception) {1371 store.writeSlotA(prior)1372 throw e1373 }1374 return true1375 }13761377 13781379 private fun requireUnlocked() {1380 if (derivedKey == null) throw IllegalStateException("vault_locked")1381 }13821383 private fun countDuplicateEntriesLocked(): Int {1384 val seen = mutableMapOf<String, VaultEntry>()1385 var duplicates = 01386 for (entry in entryList.filter { it.deletedAt == null }) {1387 val key = entry.duplicateKey()1388 val existing = seen[key]1389 if (existing == null) {1390 seen[key] = entry1391 } else {1392 duplicates++1393 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1394 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1395 if (entryStamp > existingStamp) seen[key] = entry1396 }1397 }1398 return duplicates1399 }14001401 private fun VaultEntry.duplicateKey(): String {1402 if (type == "note") return "note|${title.lowercase()}"1403 val passkeyData = passkey1404 if (passkeyData != null) {1405 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1406 }1407 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1408 }14091410 private fun takeVaultSnapshot(reason: String) {1411 runCatching {1412 val slotA = store.loadSlotA() ?: return1413 val vaultA = VaultCodec.encodeSlot(slotA)1414 val vaultB = store.loadSlotB() ?: slotA.blob1415 val history = store.loadVaultHistory()1416 val latest = history.snapshots.firstOrNull()1417 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1418 val entryCount = if (derivedKey != null && !decoyMode) {1419 bucketEntryCount(entryList.count { it.deletedAt == null })1420 } else {1421 null1422 }1423 val snapshot = VaultSnapshot(1424 id = UUID.randomUUID().toString(),1425 savedAt = System.currentTimeMillis(),1426 reason = reason,1427 vault_a = vaultA,1428 vault_b = vaultB,1429 entryCount = entryCount,1430 )1431 store.writeVaultHistory(1432 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1433 )1434 }1435 }14361437 private fun bucketEntryCount(n: Int): Int = when {1438 n <= 0 -> 01439 n <= 10 -> 101440 n <= 25 -> 251441 n <= 50 -> 501442 n <= 100 -> 1001443 n <= 250 -> 2501444 n <= 500 -> 5001445 else -> ((n + 499) / 500) * 5001446 }14471448 14491450145114521453 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1454 if (!entry.isCard()) return entry1455 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1456 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1457 }14581459 146014611462146314641465 private fun autoPurgeExpiredTrash() {1466 val days = _settings.value.trashRetentionDays ?: return1467 if (days <= 0) return1468 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1469 val before = entryList.size1470 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1471 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1472 }14731474 1475 private fun currentPayload(): VaultPayload =1476 VaultPayload(1477 entries = entryList.toList(),1478 duressConfigured = duressConfigured,1479 rootKeyB64 = rootKey?.let(::b64) ?: "",1480 recoveryWordHash = recoveryWordHash,1481 recoveryWordsSet = recoveryWordsSet,1482 )14831484 14851486148714881489 private fun ensureRootKey(payload: VaultPayload) {1490 if (payload.rootKeyB64.isNotBlank()) {1491 rootKey = unb64(payload.rootKeyB64)1492 return1493 }1494 rootKey = generateRecoverableRootKey()1495 runCatching { persistSlotA(bumpVersion = false) }1496 }14971498 private fun publish() {1499 _entries.value = entryList.filter { it.deletedAt == null }1500 }15011502 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1503 val plaintext = VaultCodec.encode(currentPayload())1504 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1505 return StoredVault(version = 1, salt = b64(salt), blob = blob)1506 }15071508 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1509 1510 1511 1512 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1513 val noise = ByteArray(approxBytes).also(rng::nextBytes)1514 return store.writeSlotB(b64(noise))1515 }15161517 1518 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1519 val key = derivedKey ?: throw IllegalStateException("vault_locked")1520 1521 if (decoyMode) {1522 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1523 val plaintext = VaultCodec.encode(currentPayload())1524 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1525 return store.writeDecoySlot(prior.copy(blob = blob))1526 }1527 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1528 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1529 val plaintext = VaultCodec.encode(currentPayload())1530 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1531 val next = prior.copy(blob = blob)1532 if (!store.writeSlotA(next)) return false1533 1534 1535 try {1536 writeMatchingNoiseSlotB(next)1537 if (bumpVersion) store.stampLocalMutation()1538 } catch (e: Exception) {1539 store.writeSlotA(prior)1540 throw e1541 }1542 return true1543 }15441545 1546 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1547 try {1548 if (!persistSlotA(bumpVersion = true)) {1549 entryList = rollback1550 throw IllegalStateException("storage_write_failed")1551 }1552 } catch (e: Exception) {1553 entryList = rollback1554 throw e1555 }1556 publish()1557 }1558}