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 271 272 val created = try {273 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")274 store.stampLocalMutation()275 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")276 true277 } catch (_: Exception) {278 279 280 store.clearVault()281 false282 }283 if (!created) return CreateVaultResult.Error("storage_write_failed")284 derivedKey = key285 publish()286 _isUnlocked.value = true287 CreateVaultResult.Success288 } catch (e: Exception) {289 CreateVaultResult.Error(e.message ?: "create_failed")290 }291 }292293 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {294 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")295 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")296 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {297 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")298 }299 var adoptedRootKey: ByteArray? = null300 var nextDerivedKey: ByteArray? = null301 var sessionRootKey: ByteArray? = null302 return try {303 adoptedRootKey = hexToRootKey(rootKeyHex)304 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {305 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")306 }307 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)308 val salt = VaultCrypto.generateSalt()309 nextDerivedKey = VaultCrypto.deriveKey(password, salt)310 sessionRootKey = adoptedRootKey.copyOf()311 val payload = VaultPayload(312 entries = emptyList(),313 duressConfigured = false,314 rootKeyB64 = b64(sessionRootKey),315 recoveryWordHash = null,316 recoveryWordsSet = false,317 )318 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))319 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)320 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")321 322 323 val created = try {324 if (!writeMatchingNoiseSlotB(slotA)) throw java.io.IOException("noise_slot_b_commit_failed")325 store.stampLocalMutation()326 if (!store.writeSettings(_settings.value)) throw java.io.IOException("settings_commit_failed")327 true328 } catch (_: Exception) {329 store.clearVault()330 false331 }332 if (!created) return AdoptVaultResult.Error("storage_write_failed")333334 val previousDerivedKey = derivedKey335 val previousRootKey = rootKey336 derivedKey = nextDerivedKey337 rootKey = sessionRootKey338 nextDerivedKey = null339 sessionRootKey = null340 entryList = mutableListOf()341 duressConfigured = false342 recoveryWordHash = null343 recoveryWordsSet = false344 decoyMode = false345 publish()346 _isUnlocked.value = true347 previousDerivedKey?.let(VaultCrypto::zero)348 previousRootKey?.let(VaultCrypto::zero)349 AdoptVaultResult.Success(vaultId)350 } catch (e: Exception) {351 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")352 } finally {353 adoptedRootKey?.let(VaultCrypto::zero)354 nextDerivedKey?.let(VaultCrypto::zero)355 sessionRootKey?.let(VaultCrypto::zero)356 }357 }358359 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }360361 362 private fun unlockLocked(password: String): UnlockResult {363 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")364 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))365 val payload = try {366 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))367 } catch (_: Exception) {368 VaultCrypto.zero(key)369 370 371 372 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {373 val legacy = tryLegacyRawUnlock(password, slotA)374 if (legacy != null) return legacy375 }376 return UnlockResult.WrongPassword377 }378 return adoptSlotA(key, payload)379 }380381 382383384385386387388389 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {390 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))391 val payload = try {392 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))393 } catch (_: Exception) {394 VaultCrypto.zero(rawKey)395 return null396 }397 398 399 val newSalt = VaultCrypto.generateSalt()400 val newKey = VaultCrypto.deriveKey(password, newSalt)401 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))402 val next = StoredVault(403 version = slotA.version,404 salt = b64(newSalt),405 blob = blob,406 recoveryEscrow = slotA.recoveryEscrow,407 rootKeyEscrow = slotA.rootKeyEscrow,408 )409 410 411 412 val priorB = store.loadSlotB()413 val healed = if (!store.writeSlotA(next)) {414 false415 } else try {416 if (!writeMatchingNoiseSlotB(next)) {417 runCatching { store.writeSlotA(slotA) }418 if (priorB != null) runCatching { store.writeSlotB(priorB) }419 false420 } else {421 store.stampLocalMutation()422 true423 }424 } catch (_: Exception) {425 runCatching { store.writeSlotA(slotA) }426 if (priorB != null) runCatching { store.writeSlotB(priorB) }427 false428 }429 return if (healed) {430 VaultCrypto.zero(rawKey)431 adoptSlotA(newKey, payload) 432 } else {433 VaultCrypto.zero(newKey)434 adoptSlotA(rawKey, payload) 435 }436 }437438 439 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {440 return try {441 derivedKey = key442 decoyMode = false443 entryList = payload.entries.toMutableList()444 duressConfigured = payload.duressConfigured445 recoveryWordHash = payload.recoveryWordHash446 recoveryWordsSet = payload.recoveryWordsSet447 _settings.value = store.loadSettings()448 ensureRootKey(payload)449 autoPurgeExpiredTrash()450 publish()451 _isUnlocked.value = true452 UnlockResult.Success453 } catch (e: Exception) {454 UnlockResult.Error(e.message ?: "unlock_failed")455 }456 }457458 459460 461462463464465466 class SlotAUnlock internal constructor(467 internal val key: ByteArray,468 internal val payload: VaultPayload,469 )470471 472473474475476477 fun tryUnlockSlotA(password: String): SlotAUnlock? {478 val slotA = store.loadSlotA() ?: return null479 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))480 return try {481 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))482 SlotAUnlock(key, payload)483 } catch (_: Exception) {484 VaultCrypto.zero(key)485 null486 }487 }488489 490 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {491 adoptSlotA(unlock.key, unlock.payload)492 }493494 495 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)496497 498499 500501502503504 class DuressUnlock internal constructor(505 internal val key: ByteArray,506 internal val payload: VaultPayload,507 )508509 510511512513514515516 fun tryUnlockDuress(password: String): DuressUnlock? {517 val slot = store.loadDecoySlot() ?: return null518 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))519 return try {520 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))521 DuressUnlock(key, payload)522 } catch (_: Exception) {523 VaultCrypto.zero(key)524 null525 }526 }527528 529 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {530 return try {531 derivedKey = unlock.key532 decoyMode = true533 entryList = unlock.payload.entries.toMutableList()534 duressConfigured = false535 recoveryWordHash = unlock.payload.recoveryWordHash536 recoveryWordsSet = unlock.payload.recoveryWordsSet537 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)538 else VaultCrypto.generateRootKey()539 _settings.value = store.loadSettings()540 publish()541 _isUnlocked.value = true542 UnlockResult.Success543 } catch (e: Exception) {544 UnlockResult.Error(e.message ?: "decoy_unlock_failed")545 }546 }547548 549 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)550551 552553554555556557 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {558 if (derivedKey == null || decoyMode) return false559 560 561 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }562 return try {563 val salt = VaultCrypto.generateSalt()564 val key = VaultCrypto.deriveKey(duressPassphrase, salt)565 566 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))567 val plaintext = VaultCodec.encode(decoyPayload)568 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))569 VaultCrypto.zero(key)570 VaultCrypto.zero(plaintext)571 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))572 if (ok) {573 duressConfigured = true574 runCatching { persistSlotA(bumpVersion = false) }575 }576 ok577 } catch (_: Exception) {578 false579 }580 }581582 583 suspend fun clearDuress(): Boolean = mutex.withLock {584 store.clearDecoySlot()585 if (derivedKey != null && !decoyMode) {586 duressConfigured = false587 runCatching { persistSlotA(bumpVersion = false) }588 }589 true590 }591592 593 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {594 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")595 return try {596 val payload = VaultCodec.decode(597 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),598 )599 derivedKey = key.copyOf()600 decoyMode = false601 entryList = payload.entries.toMutableList()602 duressConfigured = payload.duressConfigured603 recoveryWordHash = payload.recoveryWordHash604 recoveryWordsSet = payload.recoveryWordsSet605 _settings.value = store.loadSettings()606 ensureRootKey(payload)607 autoPurgeExpiredTrash()608 publish()609 _isUnlocked.value = true610 UnlockResult.Success611 } catch (_: Exception) {612 UnlockResult.WrongPassword613 }614 }615616 fun lock() {617 derivedKey?.let(VaultCrypto::zero)618 rootKey?.let(VaultCrypto::zero)619 derivedKey = null620 rootKey = null621 entryList = mutableListOf()622 duressConfigured = false623 recoveryWordHash = null624 recoveryWordsSet = false625 decoyMode = false626 _entries.value = emptyList()627 _isUnlocked.value = false628 }629630 @Volatile private var suppressNextBackgroundLock = false631632 633634635636637638639640641 fun expectTransientBackground() {642 suppressNextBackgroundLock = true643 }644645 646 fun consumeTransientBackgroundSuppression(): Boolean {647 val suppressed = suppressNextBackgroundLock648 suppressNextBackgroundLock = false649 return suppressed650 }651652 653654 655656657658659 660661662663664665666667668669670 fun syncMasterKey(): ByteArray? = if (decoyMode) null else rootKey?.copyOf()671672 673674675676677678679680681 fun sessionRootKey(): ByteArray? = rootKey?.copyOf()682683 684685 fun syncVaultId(): String? =686 if (decoyMode) null else rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }687688 689690691692693 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {694 installRootKey(generateRecoverableRootKey())695 }696697 698699700701702703704705706707708709710711712713714 suspend fun joinVault(rootKeyHex: String): AdoptVaultResult = mutex.withLock {715 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {716 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 64 lowercase hex chars")717 }718 if (derivedKey == null) return AdoptVaultResult.Error("vault_locked")719 if (decoyMode) return AdoptVaultResult.Error("JOIN_DECOY_MODE")720 if (!store.hasVault()) return AdoptVaultResult.Error("JOIN_NO_LOCAL_VAULT")721722 val incoming = hexToRootKey(rootKeyHex)723 if (incoming.size != VaultCrypto.KEY_SIZE) {724 VaultCrypto.zero(incoming)725 return AdoptVaultResult.Error("JOIN_INVALID_ROOT_KEY: expected 32 bytes")726 }727 728 729 730 val roundTrips = runCatching { wordsToRootKeyMaterial(rootKeyToWords(incoming)) }.isSuccess731 if (!roundTrips) {732 VaultCrypto.zero(incoming)733 return AdoptVaultResult.Error("JOIN_UNRECOVERABLE_ROOT_KEY")734 }735736 val vaultId = com.peachpasswords.sync.computeVaultId(incoming)737 return if (installRootKey(incoming)) {738 AdoptVaultResult.Success(vaultId)739 } else {740 AdoptVaultResult.Error("JOIN_INSTALL_FAILED")741 }742 }743744 745746747748749 fun previewRecoveryWords(rootKeyHex: String): List<String>? {750 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return null751 val bytes = hexToRootKey(rootKeyHex)752 return try {753 val words = rootKeyToWords(bytes)754 if (runCatching { wordsToRootKeyMaterial(words) }.isSuccess) words else null755 } catch (_: Exception) {756 null757 } finally {758 VaultCrypto.zero(bytes)759 }760 }761762 763764765766767 private suspend fun installRootKey(nextRootKey: ByteArray): Boolean {768 val key = derivedKey ?: return false769 if (decoyMode) return false770 val priorSlot = store.loadSlotA() ?: return false771 val priorRootKey = rootKey ?: return false772 val priorRecoveryHash = recoveryWordHash773 val priorRecoverySet = recoveryWordsSet774 val nextWords = rootKeyToWords(nextRootKey)775776 return try {777 takeVaultSnapshot("local_save")778 rootKey = nextRootKey779 recoveryWordHash = recoveryWordsHash(nextWords)780 recoveryWordsSet = false781782 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)783 ?: throw IllegalStateException("recovery_escrow_failed")784 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)785 ?: throw IllegalStateException("root_escrow_failed")786 val plaintext = VaultCodec.encode(currentPayload())787 val blob = try {788 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))789 } finally {790 VaultCrypto.zero(plaintext)791 }792 val nextSlot = priorSlot.copy(793 blob = blob,794 recoveryEscrow = nextRecoveryEscrow,795 rootKeyEscrow = nextRootKeyEscrow,796 )797 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")798 try {799 writeMatchingNoiseSlotB(nextSlot)800 store.stampLocalMutation()801 } catch (e: Exception) {802 store.writeSlotA(priorSlot)803 throw e804 }805 VaultCrypto.zero(priorRootKey)806 publish()807 true808 } catch (_: Exception) {809 rootKey = priorRootKey810 recoveryWordHash = priorRecoveryHash811 recoveryWordsSet = priorRecoverySet812 VaultCrypto.zero(nextRootKey)813 store.writeSlotA(priorSlot)814 false815 }816 }817818 819820821822823 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()824825 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {826 val slotA = store.loadSlotA() ?: return false827 var oldKey: ByteArray? = null828 var newKey: ByteArray? = null829 var plaintext: ByteArray? = null830 return try {831 oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))832 val payload = VaultCodec.decode(833 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),834 )835 val newSalt = VaultCrypto.generateSalt()836 newKey = VaultCrypto.deriveKey(newPassword, newSalt)837 838 plaintext = VaultCodec.encode(payload)839 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))840 841 842 843 844 845 846 847 val next = StoredVault(848 version = slotA.version,849 salt = b64(newSalt),850 blob = blob,851 recoveryEscrow = slotA.recoveryEscrow,852 rootKeyEscrow = slotA.rootKeyEscrow,853 )854 855 856 857 858 val priorB = store.loadSlotB()859 if (!commitVaultRotation(slotA, priorB, next)) return false860 VaultCrypto.zero(oldKey); oldKey = null861 derivedKey?.let(VaultCrypto::zero)862 derivedKey = newKey; newKey = null863 true864 } catch (_: Exception) {865 false866 } finally {867 oldKey?.let(VaultCrypto::zero)868 newKey?.let(VaultCrypto::zero)869 plaintext?.let(VaultCrypto::zero)870 }871 }872873 874875876877 suspend fun destroyVault() = mutex.withLock {878 lock()879 store.clearVault()880 store.clearDecoySlot()881 }882883 884885 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {886 requireUnlocked()887 val stamp = VaultCrypto.monotonicEntryTimestamp()888 val now = System.currentTimeMillis()889 val entry = withDerivedLast4(draft.copy(890 id = draft.id.ifBlank { UUID.randomUUID().toString() },891 createdAt = if (draft.createdAt > 0) draft.createdAt else now,892 modifiedAt = stamp,893 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,894 ))895 val rollback = entryList.toMutableList()896 entryList.add(entry)897 persistOrRollback(rollback)898 entry899 }900901 902903904905906907908909910 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {911 requireUnlocked()912 if (drafts.isEmpty()) return 0913 val rollback = entryList.toMutableList()914 val now = System.currentTimeMillis()915 for (draft in drafts) {916 val stamp = VaultCrypto.monotonicEntryTimestamp()917 entryList.add(918 withDerivedLast4(919 draft.copy(920 id = draft.id.ifBlank { UUID.randomUUID().toString() },921 createdAt = if (draft.createdAt > 0) draft.createdAt else now,922 modifiedAt = stamp,923 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,924 ),925 ),926 )927 }928 persistOrRollback(rollback)929 return drafts.size930 }931932 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {933 requireUnlocked()934 val idx = entryList.indexOfFirst { it.id == patch.id }935 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")936 val current = entryList[idx]937 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)938 939 val passwordChanged = patch.password != null && patch.password != current.password940 941 942 943 944 945 946 val priorPassword = current.password947 val shouldCapture = passwordChanged &&948 !priorPassword.isNullOrEmpty() &&949 current.passwordHistory?.firstOrNull()?.password != priorPassword950 val nextHistory = if (shouldCapture) {951 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +952 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)953 } else {954 current.passwordHistory955 }956 val updated = withDerivedLast4(patch.copy(957 createdAt = current.createdAt,958 modifiedAt = stamp,959 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,960 passwordHistory = nextHistory,961 ))962 val rollback = entryList.toMutableList()963 entryList[idx] = updated964 persistOrRollback(rollback)965 updated966 }967968 969 suspend fun deleteEntry(id: String) = mutex.withLock {970 requireUnlocked()971 val idx = entryList.indexOfFirst { it.id == id }972 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")973 val current = entryList[idx]974 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)975 val rollback = entryList.toMutableList()976 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)977 persistOrRollback(rollback)978 }979980 suspend fun restoreEntry(id: String) = mutex.withLock {981 requireUnlocked()982 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }983 if (idx == -1) throw IllegalStateException("deleted entry not found")984 val current = entryList[idx]985 val rollback = entryList.toMutableList()986 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))987 persistOrRollback(rollback)988 }989990 991 suspend fun purgeEntry(id: String) = mutex.withLock {992 requireUnlocked()993 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }994 if (idx == -1) throw IllegalStateException("deleted entry not found")995 val rollback = entryList.toMutableList()996 entryList.removeAt(idx)997 persistOrRollback(rollback)998 }9991000 1001 suspend fun purgeDeletedEntries(): Int = mutex.withLock {1002 requireUnlocked()1003 val rollback = entryList.toMutableList()1004 val before = entryList.size1005 entryList.removeAll { it.deletedAt != null }1006 val removed = before - entryList.size1007 if (removed > 0) persistOrRollback(rollback)1008 removed1009 }10101011 1012 suspend fun countDuplicateEntries(): Int = mutex.withLock {1013 requireUnlocked()1014 countDuplicateEntriesLocked()1015 }10161017 10181019102010211022 suspend fun deduplicateEntries(): Int = mutex.withLock {1023 requireUnlocked()1024 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")1025 val active = entryList.filter { it.deletedAt == null }1026 val keepIds = mutableSetOf<String>()1027 active.groupBy { it.duplicateKey() }.values.forEach { group ->1028 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id1029 }10301031 val rollback = entryList.toMutableList()1032 var removed = 01033 entryList = entryList.map { entry ->1034 if (entry.deletedAt == null && entry.id !in keepIds) {1035 removed++1036 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)1037 entry.copy(deletedAt = stamp, modifiedAt = stamp)1038 } else {1039 entry1040 }1041 }.toMutableList()1042 if (removed > 0) persistOrRollback(rollback)1043 removed1044 }10451046 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }1047 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }1048 fun getDeletedEntries(): List<VaultEntry> =1049 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }10501051 1052 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {1053 val active = entryList.filter { it.deletedAt == null }1054 if (tagFilter == null) return active1055 val needle = tagFilter.trim().lowercase()1056 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }1057 }10581059 1060 fun getDistinctTags(): List<String> =1061 entryList.asSequence()1062 .filter { it.deletedAt == null }1063 .flatMap { it.tags?.asSequence() ?: emptySequence() }1064 .map { it.trim().lowercase() }1065 .filter { it.isNotEmpty() }1066 .distinct()1067 .sorted()1068 .toList()10691070 107110721073107410751076 fun getDistinctFieldValues(field: String): List<String> {1077 val select: (VaultEntry) -> String? = when (field) {1078 "username" -> { e -> e.username }1079 "email" -> { e -> e.email }1080 else -> return emptyList()1081 }1082 return entryList.asSequence()1083 .filter { it.deletedAt == null }1084 .mapNotNull { select(it)?.trim() }1085 .filter { it.isNotEmpty() }1086 .groupingBy { it.lowercase() }1087 .aggregate { _, acc: Pair<String, Int>?, value, _ ->1088 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)1089 }1090 .values1091 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })1092 .map { it.first }1093 }10941095 fun getTombstones(): List<Tombstone> =1096 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }1097 fun getVaultVersion(): Long = store.version()1098 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots10991100 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {1101 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false1102 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false1103 val priorA = store.loadSlotA()1104 val priorB = store.loadSlotB()1105 1106 1107 1108 1109 1110 val priorHistory = store.loadVaultHistory()1111 1112 1113 1114 1115 if (!takeVaultSnapshot("restore")) {1116 return false1117 }1118 if (!commitRestoreAtomic(restoredA, snapshot.vault_b, priorA, priorB)) {1119 store.writeVaultHistory(priorHistory)1120 return false1121 }1122 lock()1123 true1124 }11251126 11271128112911301131113211331134 private fun commitRestoreAtomic(1135 nextA: StoredVault,1136 nextB: String,1137 priorA: StoredVault?,1138 priorB: String?,1139 ): Boolean {1140 if (!store.writeSlotA(nextA)) return false1141 1142 1143 1144 try {1145 if (!store.writeSlotB(nextB)) throw java.io.IOException("slot_b_commit_failed")1146 store.stampLocalMutation()1147 } catch (_: Exception) {1148 rollbackRestore(priorA, priorB)1149 return false1150 }1151 return true1152 }11531154 11551156 11571158115911601161 suspend fun applyMergedRemoteEntries(1162 remoteEntries: List<VaultEntry>,1163 remoteTombstones: List<Tombstone>,1164 ): Int = mutex.withLock {1165 requireUnlocked()1166 1167 1168 1169 1170 1171 val mergeNow = System.currentTimeMillis()1172 val clamped = remoteEntries.map { entry ->1173 entry.copy(1174 modifiedAt = SyncLimits.clampSyncTimestamp(entry.modifiedAt, mergeNow),1175 deletedAt = entry.deletedAt?.let {1176 SyncLimits.clampSyncTimestamp(it, mergeNow)1177 },1178 )1179 }1180 val tombMap = remoteTombstones.associate {1181 it.id to SyncLimits.clampSyncTimestamp(it.deletedAt, mergeNow)1182 }1183 var applied = 01184 val byId = entryList.associateBy { it.id }.toMutableMap()11851186 for (remote in clamped) {1187 val tomb = tombMap[remote.id]1188 if (tomb != null && tomb >= remote.modifiedAt) continue1189 val local = byId[remote.id]1190 if (local == null) {1191 entryList.add(remote); byId[remote.id] = remote; applied++1192 } else if (remote.modifiedAt > local.modifiedAt) {1193 val i = entryList.indexOfFirst { it.id == local.id }1194 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }1195 }1196 }11971198 for ((id, deletedAt) in tombMap) {1199 val local = byId[id] ?: continue1200 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue1201 if (deletedAt < local.modifiedAt) continue1202 val i = entryList.indexOfFirst { it.id == id }1203 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }1204 }12051206 if (applied > 0) {1207 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")1208 publish()1209 }1210 applied1211 }12121213 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {1214 _settings.value = settings1215 store.writeSettings(settings)1216 }12171218 12191220 1221 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet12221223 12241225122612271228 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {1229 requireUnlocked()1230 recoveryWordHash = recoveryWordsHash(words)1231 recoveryWordsSet = true1232 1233 1234 1235 1236 runCatching {1237 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))1238 }.getOrDefault(false)1239 }12401241 1242 fun verifyRecoveryWords(words: List<String>): Boolean {1243 val stored = recoveryWordHash ?: return false1244 return stored == recoveryWordsHash(words)1245 }12461247 12481249 1250 fun hasRecoveryEscrow(): Boolean =1251 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false12521253 125412551256125712581259126012611262126312641265 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1266 val slotA = store.loadSlotA() ?: return false1267 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false12681269 1270 1271 slotA.recoveryEscrow?.let { escrow ->1272 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1273 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1274 val keyBytes = recoveryKey.encoded1275 try {1276 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1277 val payload = VaultCodec.decode(plaintext)1278 decoyMode = false1279 entryList = payload.entries.toMutableList()1280 duressConfigured = payload.duressConfigured1281 recoveryWordHash = payload.recoveryWordHash1282 recoveryWordsSet = payload.recoveryWordsSet1283 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1284 else generateRecoverableRootKey()1285 _settings.value = store.loadSettings()1286 VaultCrypto.zero(plaintext)1287 return true1288 } catch (_: Exception) {1289 1290 } finally {1291 VaultCrypto.zero(material)1292 VaultCrypto.zero(keyBytes)1293 }1294 }12951296 1297 1298 1299 slotA.rootKeyEscrow?.let { escrow ->1300 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1301 decoyMode = false1302 entryList = mutableListOf()1303 duressConfigured = false1304 recoveryWordHash = recoveryWordsHash(words)1305 recoveryWordsSet = true1306 rootKey = rk1307 _settings.value = store.loadSettings()1308 return true1309 }1310 return false1311 }13121313 131413151316131713181319132013211322 suspend fun resetPasswordWithRecoveryWords(1323 words: List<String>,1324 newPassword: String,1325 ): Boolean = mutex.withLock {1326 if (rootKey == null) return false 1327 val priorA = store.loadSlotA() ?: return false1328 var newKey: ByteArray? = null1329 var plaintext: ByteArray? = null1330 return try {1331 val newSalt = VaultCrypto.generateSalt()1332 newKey = VaultCrypto.deriveKey(newPassword, newSalt)1333 plaintext = VaultCodec.encode(currentPayload())1334 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1335 1336 1337 1338 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1339 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: priorA.recoveryEscrow else priorA.recoveryEscrow1340 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: priorA.rootKeyEscrow else priorA.rootKeyEscrow1341 val next = StoredVault(1342 version = priorA.version,1343 salt = b64(newSalt),1344 blob = blob,1345 recoveryEscrow = freshRecovery,1346 rootKeyEscrow = freshRootKey,1347 )1348 1349 1350 1351 val priorB = store.loadSlotB()1352 if (!commitVaultRotation(priorA, priorB, next)) return false1353 derivedKey?.let(VaultCrypto::zero)1354 derivedKey = newKey; newKey = null1355 publish()1356 _isUnlocked.value = true1357 true1358 } catch (_: Exception) {1359 false1360 } finally {1361 newKey?.let(VaultCrypto::zero)1362 plaintext?.let(VaultCrypto::zero)1363 }1364 }13651366 136713681369137013711372 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1373 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1374 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1375 val salt = VaultCrypto.generateSalt()1376 val recoveryKey = deriveRecoveryKey(material, salt)1377 val keyBytes = recoveryKey.encoded1378 val plaintext = VaultCodec.encode(currentPayload())1379 return try {1380 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1381 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1382 } finally {1383 VaultCrypto.zero(material)1384 VaultCrypto.zero(keyBytes)1385 VaultCrypto.zero(plaintext)1386 }1387 }13881389 13901391139213931394139513961397 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1398 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1399 val rk = rootKey ?: return null1400 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1401 val salt = VaultCrypto.generateSalt()1402 val recoveryKey = deriveRecoveryKey(material, salt)1403 val keyBytes = recoveryKey.encoded1404 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1405 return try {1406 1407 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1408 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1409 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1410 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1411 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1412 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1413 } finally {1414 VaultCrypto.zero(material)1415 VaultCrypto.zero(keyBytes)1416 VaultCrypto.zero(plaintext)1417 }1418 }14191420 1421 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1422 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1423 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1424 val keyBytes = recoveryKey.encoded1425 return try {1426 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1427 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1428 hexToRootKey(String(pt, Charsets.US_ASCII))1429 } catch (_: Exception) {1430 null1431 } finally {1432 VaultCrypto.zero(material)1433 VaultCrypto.zero(keyBytes)1434 }1435 }14361437 143814391440144114421443 private fun persistSlotAWithEscrows(1444 recoveryEscrow: RecoveryEscrow?,1445 rootKeyEscrow: RootKeyEscrow?,1446 ): Boolean {1447 val key = derivedKey ?: throw IllegalStateException("vault_locked")1448 if (decoyMode) return false1449 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1450 val plaintext = VaultCodec.encode(currentPayload())1451 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1452 val next = prior.copy(1453 blob = blob,1454 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1455 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1456 )1457 if (!store.writeSlotA(next)) return false1458 try {1459 writeMatchingNoiseSlotB(next)1460 } catch (e: Exception) {1461 store.writeSlotA(prior)1462 throw e1463 }1464 return true1465 }14661467 14681469 private fun requireUnlocked() {1470 if (derivedKey == null) throw IllegalStateException("vault_locked")1471 }14721473 private fun countDuplicateEntriesLocked(): Int {1474 val seen = mutableMapOf<String, VaultEntry>()1475 var duplicates = 01476 for (entry in entryList.filter { it.deletedAt == null }) {1477 val key = entry.duplicateKey()1478 val existing = seen[key]1479 if (existing == null) {1480 seen[key] = entry1481 } else {1482 duplicates++1483 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1484 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1485 if (entryStamp > existingStamp) seen[key] = entry1486 }1487 }1488 return duplicates1489 }14901491 private fun VaultEntry.duplicateKey(): String {1492 if (type == "note") return "note|${title.lowercase()}"1493 val passkeyData = passkey1494 if (passkeyData != null) {1495 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1496 }1497 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1498 }14991500 150115021503150415051506 private fun takeVaultSnapshot(reason: String): Boolean {1507 val slotA = store.loadSlotA() ?: return false1508 val vaultA = VaultCodec.encodeSlot(slotA)1509 val vaultB = store.loadSlotB() ?: slotA.blob1510 val history = store.loadVaultHistory()1511 val latest = history.snapshots.firstOrNull()1512 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return true1513 val entryCount = if (derivedKey != null && !decoyMode) {1514 bucketEntryCount(entryList.count { it.deletedAt == null })1515 } else {1516 null1517 }1518 return try {1519 val snapshot = VaultSnapshot(1520 id = UUID.randomUUID().toString(),1521 savedAt = System.currentTimeMillis(),1522 reason = reason,1523 vault_a = vaultA,1524 vault_b = vaultB,1525 entryCount = entryCount,1526 )1527 1528 1529 1530 store.writeVaultHistory(1531 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1532 )1533 } catch (_: Exception) {1534 false1535 }1536 }15371538 private fun bucketEntryCount(n: Int): Int = when {1539 n <= 0 -> 01540 n <= 10 -> 101541 n <= 25 -> 251542 n <= 50 -> 501543 n <= 100 -> 1001544 n <= 250 -> 2501545 n <= 500 -> 5001546 else -> ((n + 499) / 500) * 5001547 }15481549 15501551155215531554 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1555 if (!entry.isCard()) return entry1556 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1557 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1558 }15591560 156115621563156415651566 private fun autoPurgeExpiredTrash() {1567 val days = _settings.value.trashRetentionDays ?: return1568 if (days <= 0) return1569 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1570 val before = entryList.size1571 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1572 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1573 }15741575 1576 private fun currentPayload(): VaultPayload =1577 VaultPayload(1578 entries = entryList.toList(),1579 duressConfigured = duressConfigured,1580 rootKeyB64 = rootKey?.let(::b64) ?: "",1581 recoveryWordHash = recoveryWordHash,1582 recoveryWordsSet = recoveryWordsSet,1583 )15841585 15861587158815891590 private fun ensureRootKey(payload: VaultPayload) {1591 if (payload.rootKeyB64.isNotBlank()) {1592 rootKey = unb64(payload.rootKeyB64)1593 return1594 }1595 rootKey = generateRecoverableRootKey()1596 runCatching { persistSlotA(bumpVersion = false) }1597 }15981599 private fun publish() {1600 _entries.value = entryList.filter { it.deletedAt == null }1601 }16021603 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1604 val plaintext = VaultCodec.encode(currentPayload())1605 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1606 return StoredVault(version = 1, salt = b64(salt), blob = blob)1607 }16081609 private fun writeMatchingNoiseSlotB(slotA: StoredVault): Boolean {1610 1611 1612 1613 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1614 val noise = ByteArray(approxBytes).also(rng::nextBytes)1615 return store.writeSlotB(b64(noise))1616 }16171618 1619 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1620 val key = derivedKey ?: throw IllegalStateException("vault_locked")1621 1622 if (decoyMode) {1623 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1624 val plaintext = VaultCodec.encode(currentPayload())1625 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1626 return store.writeDecoySlot(prior.copy(blob = blob))1627 }1628 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1629 1630 1631 1632 1633 val priorB = store.loadSlotB()1634 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1635 val plaintext = VaultCodec.encode(currentPayload())1636 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1637 val next = prior.copy(blob = blob)1638 if (!store.writeSlotA(next)) return false1639 1640 1641 1642 1643 try {1644 if (!writeMatchingNoiseSlotB(next)) throw java.io.IOException("noise_slot_b_commit_failed")1645 if (bumpVersion) store.stampLocalMutation()1646 } catch (e: Exception) {1647 store.writeSlotA(prior)1648 if (priorB != null) runCatching { store.writeSlotB(priorB) }1649 throw e1650 }1651 return true1652 }16531654 1655165616571658165916601661 private fun commitVaultRotation(1662 priorA: StoredVault,1663 priorB: String?,1664 nextA: StoredVault,1665 ): Boolean {1666 if (!store.writeSlotA(nextA)) return false1667 1668 1669 1670 try {1671 if (!writeMatchingNoiseSlotB(nextA)) throw java.io.IOException("noise_slot_b_commit_failed")1672 store.stampLocalMutation()1673 } catch (_: Exception) {1674 rollbackRotation(priorA, priorB)1675 return false1676 }1677 return true1678 }16791680 16811682168316841685 private fun rollbackRotation(priorA: StoredVault, priorB: String?) {1686 val failures = mutableListOf<String>()1687 runCatching { if (!store.writeSlotA(priorA)) failures += "writeSlotA returned false" }1688 .onFailure { failures += "writeSlotA: $it" }1689 if (priorB != null) {1690 runCatching { if (!store.writeSlotB(priorB)) failures += "writeSlotB returned false" }1691 .onFailure { failures += "writeSlotB: $it" }1692 }1693 if (failures.isNotEmpty()) runCatching { Log.w("PeachVault", "vault rotation rollback incomplete: $failures") }1694 }16951696 1697 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1698 try {1699 if (!persistSlotA(bumpVersion = true)) {1700 entryList = rollback1701 throw IllegalStateException("storage_write_failed")1702 }1703 } catch (e: Exception) {1704 entryList = rollback1705 throw e1706 }1707 publish()1708 }1709}