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 kotlinx.serialization.json.JsonElement14import kotlinx.serialization.json.JsonNull15import kotlinx.serialization.json.JsonObject16import kotlinx.serialization.json.JsonPrimitive17import java.util.Base6418import java.util.UUID19import javax.inject.Inject20import javax.inject.Singleton212223private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2425private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)26private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)272829private const val PASSWORD_HISTORY_MAX = 5030private const val VAULT_HISTORY_MAX = 53132sealed interface UnlockResult {33 data object Success : UnlockResult34 data object WrongPassword : UnlockResult35 data class Error(val reason: String) : UnlockResult36}3738sealed interface CreateVaultResult {39 data object Success : CreateVaultResult40 data class Error(val reason: String) : CreateVaultResult41}4243sealed interface AdoptVaultResult {44 data class Success(val vaultId: String) : AdoptVaultResult45 data class Error(val reason: String) : AdoptVaultResult46}474849505152535455565758596061@Singleton62class VaultRepository @Inject constructor(63 private val store: VaultStorage,64) {65 private val mutex = Mutex()66 private val rng = SecureRandom()6768 private var derivedKey: ByteArray? = null69 private var rootKey: ByteArray? = null70 private var entryList: MutableList<VaultEntry> = mutableListOf()71 private var duressConfigured: Boolean = false72 private var recoveryWordHash: String? = null73 private var recoveryWordsSet: Boolean = false74 75 private var decoyMode: Boolean = false7677 private val _isUnlocked = MutableStateFlow(false)78 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7980 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())81 82 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8384 private val _settings = MutableStateFlow(VaultSettings())85 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8687 fun hasVault(): Boolean = store.hasVault()8889 9091 9293949596 fun codexVaultBlobBase64(): String? {97 val slotA = store.loadSlotA() ?: return null98 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""99 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))100 }101102 103104105106107 108 private fun JsonElement?.asSlotJson(): String? = when {109 this == null || this is JsonNull -> null110 this is JsonPrimitive && isString -> content111 else -> toString()112 }113114 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {115 return try {116 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)117 118 119 120 121 122 123 124 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject125 ?: return UnlockResult.Error("invalid_codex_blob")126 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")127 128 129 130 131 132 val slotA = VaultCodec.decodeSlot(slotJson)133 ?: BrowserSlot.parse(slotJson)?.let {134 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword135 }136 ?: return UnlockResult.Error("invalid_codex_blob")137 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")138 139 140 val slotB = wrapper["b"].asSlotJson()141 if (slotB != null) {142 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))143 } else {144 writeMatchingNoiseSlotB(slotA)145 }146 store.stampLocalMutation()147 unlockLocked(password)148 } catch (e: Exception) {149 150 151 152 153 154 155 if (BuildConfig.DEBUG) Log.w("PeachCodex", codexFailureShape(vaultBlobBase64, e))156 UnlockResult.Error(e.message ?: "restore_failed")157 }158 }159160 161 private fun codexFailureShape(vaultBlobBase64: String, e: Exception): String = runCatching {162 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)163 val offset = Regex("offset (\\d+)").find(e.message.orEmpty())?.groupValues?.get(1)?.toIntOrNull()164 val window = offset?.let {165 val from = (it - 40).coerceAtLeast(0)166 val to = (it + 40).coerceAtMost(wrapperJson.length)167 wrapperJson.substring(from, to).replace(Regex("[A-Za-z0-9+/=_-]"), "x")168 }169 val wrapper = runCatching { CodexJson.parseToJsonElement(wrapperJson) as? JsonObject }.getOrNull()170 val slot = wrapper?.get("a")171 buildString {172 append("codex restore failed: ").append(e::class.simpleName).append(": ").append(e.message)173 append(" | blobBase64=").append(vaultBlobBase64.length)174 append(" wrapperChars=").append(wrapperJson.length)175 append(" wrapperParses=").append(wrapper != null)176 append(" keys=").append(wrapper?.keys)177 append(" slotAKind=").append(178 when {179 slot == null -> "absent"180 slot is JsonPrimitive && slot.isString -> "string"181 slot is JsonObject -> "object:" + slot.keys182 else -> slot::class.simpleName183 },184 )185 if (window != null) append(" masked@").append(offset).append("=").append(window)186 }187 }.getOrElse { "codex restore failed: ${e.message} (shape unavailable: $it)" }188189 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {190 return try {191 val salt = VaultCrypto.generateSalt()192 val key = VaultCrypto.deriveKey(password, salt)193 entryList = mutableListOf()194 duressConfigured = false195 recoveryWordHash = null196 recoveryWordsSet = false197 decoyMode = false198 rootKey = generateRecoverableRootKey()199 val slotA = sealSlotA(key, salt)200 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")201 writeMatchingNoiseSlotB(slotA)202 store.stampLocalMutation()203 store.writeSettings(_settings.value)204 derivedKey = key205 publish()206 _isUnlocked.value = true207 CreateVaultResult.Success208 } catch (e: Exception) {209 CreateVaultResult.Error(e.message ?: "create_failed")210 }211 }212213 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {214 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")215 if (store.hasVault()) return AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS")216 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {217 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")218 }219 var adoptedRootKey: ByteArray? = null220 var nextDerivedKey: ByteArray? = null221 var sessionRootKey: ByteArray? = null222 return try {223 adoptedRootKey = hexToRootKey(rootKeyHex)224 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {225 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")226 }227 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)228 val salt = VaultCrypto.generateSalt()229 nextDerivedKey = VaultCrypto.deriveKey(password, salt)230 sessionRootKey = adoptedRootKey.copyOf()231 val payload = VaultPayload(232 entries = emptyList(),233 duressConfigured = false,234 rootKeyB64 = b64(sessionRootKey),235 recoveryWordHash = null,236 recoveryWordsSet = false,237 )238 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))239 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)240 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")241 writeMatchingNoiseSlotB(slotA)242 store.stampLocalMutation()243 store.writeSettings(_settings.value)244245 val previousDerivedKey = derivedKey246 val previousRootKey = rootKey247 derivedKey = nextDerivedKey248 rootKey = sessionRootKey249 nextDerivedKey = null250 sessionRootKey = null251 entryList = mutableListOf()252 duressConfigured = false253 recoveryWordHash = null254 recoveryWordsSet = false255 decoyMode = false256 publish()257 _isUnlocked.value = true258 previousDerivedKey?.let(VaultCrypto::zero)259 previousRootKey?.let(VaultCrypto::zero)260 AdoptVaultResult.Success(vaultId)261 } catch (e: Exception) {262 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")263 } finally {264 adoptedRootKey?.let(VaultCrypto::zero)265 nextDerivedKey?.let(VaultCrypto::zero)266 sessionRootKey?.let(VaultCrypto::zero)267 }268 }269270 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }271272 273 private fun unlockLocked(password: String): UnlockResult {274 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")275 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))276 val payload = try {277 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))278 } catch (_: Exception) {279 VaultCrypto.zero(key)280 281 282 283 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {284 val legacy = tryLegacyRawUnlock(password, slotA)285 if (legacy != null) return legacy286 }287 return UnlockResult.WrongPassword288 }289 return adoptSlotA(key, payload)290 }291292 293294295296297298 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {299 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))300 val payload = try {301 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))302 } catch (_: Exception) {303 VaultCrypto.zero(rawKey)304 return null305 }306 307 308 val newSalt = VaultCrypto.generateSalt()309 val newKey = VaultCrypto.deriveKey(password, newSalt)310 val healed = try {311 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))312 val next = StoredVault(313 version = slotA.version,314 salt = b64(newSalt),315 blob = blob,316 recoveryEscrow = slotA.recoveryEscrow,317 rootKeyEscrow = slotA.rootKeyEscrow,318 )319 if (store.writeSlotA(next)) {320 writeMatchingNoiseSlotB(next)321 store.stampLocalMutation()322 true323 } else {324 false325 }326 } catch (_: Exception) {327 false328 }329 return if (healed) {330 VaultCrypto.zero(rawKey)331 adoptSlotA(newKey, payload) 332 } else {333 VaultCrypto.zero(newKey)334 adoptSlotA(rawKey, payload) 335 }336 }337338 339 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {340 return try {341 derivedKey = key342 decoyMode = false343 entryList = payload.entries.toMutableList()344 duressConfigured = payload.duressConfigured345 recoveryWordHash = payload.recoveryWordHash346 recoveryWordsSet = payload.recoveryWordsSet347 _settings.value = store.loadSettings()348 ensureRootKey(payload)349 autoPurgeExpiredTrash()350 publish()351 _isUnlocked.value = true352 UnlockResult.Success353 } catch (e: Exception) {354 UnlockResult.Error(e.message ?: "unlock_failed")355 }356 }357358 359360 361362363364365366 class SlotAUnlock internal constructor(367 internal val key: ByteArray,368 internal val payload: VaultPayload,369 )370371 372373374375376377 fun tryUnlockSlotA(password: String): SlotAUnlock? {378 val slotA = store.loadSlotA() ?: return null379 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))380 return try {381 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))382 SlotAUnlock(key, payload)383 } catch (_: Exception) {384 VaultCrypto.zero(key)385 null386 }387 }388389 390 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {391 adoptSlotA(unlock.key, unlock.payload)392 }393394 395 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)396397 398399 400401402403404 class DuressUnlock internal constructor(405 internal val key: ByteArray,406 internal val payload: VaultPayload,407 )408409 410411412413414415416 fun tryUnlockDuress(password: String): DuressUnlock? {417 val slot = store.loadDecoySlot() ?: return null418 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))419 return try {420 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))421 DuressUnlock(key, payload)422 } catch (_: Exception) {423 VaultCrypto.zero(key)424 null425 }426 }427428 429 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {430 return try {431 derivedKey = unlock.key432 decoyMode = true433 entryList = unlock.payload.entries.toMutableList()434 duressConfigured = false435 recoveryWordHash = unlock.payload.recoveryWordHash436 recoveryWordsSet = unlock.payload.recoveryWordsSet437 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)438 else VaultCrypto.generateRootKey()439 _settings.value = store.loadSettings()440 publish()441 _isUnlocked.value = true442 UnlockResult.Success443 } catch (e: Exception) {444 UnlockResult.Error(e.message ?: "decoy_unlock_failed")445 }446 }447448 449 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)450451 452453454455456457 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {458 if (derivedKey == null || decoyMode) return false459 460 461 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }462 return try {463 val salt = VaultCrypto.generateSalt()464 val key = VaultCrypto.deriveKey(duressPassphrase, salt)465 466 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))467 val plaintext = VaultCodec.encode(decoyPayload)468 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))469 VaultCrypto.zero(key)470 VaultCrypto.zero(plaintext)471 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))472 if (ok) {473 duressConfigured = true474 runCatching { persistSlotA(bumpVersion = false) }475 }476 ok477 } catch (_: Exception) {478 false479 }480 }481482 483 suspend fun clearDuress(): Boolean = mutex.withLock {484 store.clearDecoySlot()485 if (derivedKey != null && !decoyMode) {486 duressConfigured = false487 runCatching { persistSlotA(bumpVersion = false) }488 }489 true490 }491492 493 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {494 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")495 return try {496 val payload = VaultCodec.decode(497 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),498 )499 derivedKey = key.copyOf()500 decoyMode = false501 entryList = payload.entries.toMutableList()502 duressConfigured = payload.duressConfigured503 recoveryWordHash = payload.recoveryWordHash504 recoveryWordsSet = payload.recoveryWordsSet505 _settings.value = store.loadSettings()506 ensureRootKey(payload)507 autoPurgeExpiredTrash()508 publish()509 _isUnlocked.value = true510 UnlockResult.Success511 } catch (_: Exception) {512 UnlockResult.WrongPassword513 }514 }515516 fun lock() {517 derivedKey?.let(VaultCrypto::zero)518 rootKey?.let(VaultCrypto::zero)519 derivedKey = null520 rootKey = null521 entryList = mutableListOf()522 duressConfigured = false523 recoveryWordHash = null524 recoveryWordsSet = false525 decoyMode = false526 _entries.value = emptyList()527 _isUnlocked.value = false528 }529530 @Volatile private var suppressNextBackgroundLock = false531532 533534535536537538539540541 fun expectTransientBackground() {542 suppressNextBackgroundLock = true543 }544545 546 fun consumeTransientBackgroundSuppression(): Boolean {547 val suppressed = suppressNextBackgroundLock548 suppressNextBackgroundLock = false549 return suppressed550 }551552 553554 555556557558559 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()560561 562 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }563564 565566567568569 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {570 val key = derivedKey ?: return false571 if (decoyMode) return false572 val priorSlot = store.loadSlotA() ?: return false573 val priorRootKey = rootKey ?: return false574 val priorRecoveryHash = recoveryWordHash575 val priorRecoverySet = recoveryWordsSet576 val nextRootKey = generateRecoverableRootKey()577 val nextWords = rootKeyToWords(nextRootKey)578579 try {580 takeVaultSnapshot("local_save")581 rootKey = nextRootKey582 recoveryWordHash = recoveryWordsHash(nextWords)583 recoveryWordsSet = false584585 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)586 ?: throw IllegalStateException("recovery_escrow_failed")587 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)588 ?: throw IllegalStateException("root_escrow_failed")589 val plaintext = VaultCodec.encode(currentPayload())590 val blob = try {591 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))592 } finally {593 VaultCrypto.zero(plaintext)594 }595 val nextSlot = priorSlot.copy(596 blob = blob,597 recoveryEscrow = nextRecoveryEscrow,598 rootKeyEscrow = nextRootKeyEscrow,599 )600 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")601 try {602 writeMatchingNoiseSlotB(nextSlot)603 store.stampLocalMutation()604 } catch (e: Exception) {605 store.writeSlotA(priorSlot)606 throw e607 }608 VaultCrypto.zero(priorRootKey)609 publish()610 true611 } catch (_: Exception) {612 rootKey = priorRootKey613 recoveryWordHash = priorRecoveryHash614 recoveryWordsSet = priorRecoverySet615 VaultCrypto.zero(nextRootKey)616 store.writeSlotA(priorSlot)617 false618 }619 }620621 622623624625626 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()627628 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {629 val slotA = store.loadSlotA() ?: return false630 return try {631 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))632 val payload = VaultCodec.decode(633 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),634 )635 val newSalt = VaultCrypto.generateSalt()636 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)637 638 val plaintext = VaultCodec.encode(payload)639 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))640 641 642 643 644 645 646 647 val next = StoredVault(648 version = slotA.version,649 salt = b64(newSalt),650 blob = blob,651 recoveryEscrow = slotA.recoveryEscrow,652 rootKeyEscrow = slotA.rootKeyEscrow,653 )654 if (!store.writeSlotA(next)) return false655 writeMatchingNoiseSlotB(next)656 store.stampLocalMutation()657 VaultCrypto.zero(oldKey)658 derivedKey?.let(VaultCrypto::zero)659 derivedKey = newKey660 true661 } catch (_: Exception) {662 false663 }664 }665666 667668669670 suspend fun destroyVault() = mutex.withLock {671 lock()672 store.clearVault()673 store.clearDecoySlot()674 }675676 677678 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {679 requireUnlocked()680 val stamp = VaultCrypto.monotonicEntryTimestamp()681 val now = System.currentTimeMillis()682 val entry = withDerivedLast4(draft.copy(683 id = draft.id.ifBlank { UUID.randomUUID().toString() },684 createdAt = if (draft.createdAt > 0) draft.createdAt else now,685 modifiedAt = stamp,686 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,687 ))688 val rollback = entryList.toMutableList()689 entryList.add(entry)690 persistOrRollback(rollback)691 entry692 }693694 695696697698699700701702703 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {704 requireUnlocked()705 if (drafts.isEmpty()) return 0706 val rollback = entryList.toMutableList()707 val now = System.currentTimeMillis()708 for (draft in drafts) {709 val stamp = VaultCrypto.monotonicEntryTimestamp()710 entryList.add(711 withDerivedLast4(712 draft.copy(713 id = draft.id.ifBlank { UUID.randomUUID().toString() },714 createdAt = if (draft.createdAt > 0) draft.createdAt else now,715 modifiedAt = stamp,716 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,717 ),718 ),719 )720 }721 persistOrRollback(rollback)722 return drafts.size723 }724725 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {726 requireUnlocked()727 val idx = entryList.indexOfFirst { it.id == patch.id }728 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")729 val current = entryList[idx]730 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)731 732 val passwordChanged = patch.password != null && patch.password != current.password733 734 735 736 737 738 739 val priorPassword = current.password740 val shouldCapture = passwordChanged &&741 !priorPassword.isNullOrEmpty() &&742 current.passwordHistory?.firstOrNull()?.password != priorPassword743 val nextHistory = if (shouldCapture) {744 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +745 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)746 } else {747 current.passwordHistory748 }749 val updated = withDerivedLast4(patch.copy(750 createdAt = current.createdAt,751 modifiedAt = stamp,752 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,753 passwordHistory = nextHistory,754 ))755 val rollback = entryList.toMutableList()756 entryList[idx] = updated757 persistOrRollback(rollback)758 updated759 }760761 762 suspend fun deleteEntry(id: String) = mutex.withLock {763 requireUnlocked()764 val idx = entryList.indexOfFirst { it.id == id }765 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")766 val current = entryList[idx]767 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)768 val rollback = entryList.toMutableList()769 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)770 persistOrRollback(rollback)771 }772773 suspend fun restoreEntry(id: String) = mutex.withLock {774 requireUnlocked()775 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }776 if (idx == -1) throw IllegalStateException("deleted entry not found")777 val current = entryList[idx]778 val rollback = entryList.toMutableList()779 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))780 persistOrRollback(rollback)781 }782783 784 suspend fun purgeEntry(id: String) = mutex.withLock {785 requireUnlocked()786 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }787 if (idx == -1) throw IllegalStateException("deleted entry not found")788 val rollback = entryList.toMutableList()789 entryList.removeAt(idx)790 persistOrRollback(rollback)791 }792793 794 suspend fun purgeDeletedEntries(): Int = mutex.withLock {795 requireUnlocked()796 val rollback = entryList.toMutableList()797 val before = entryList.size798 entryList.removeAll { it.deletedAt != null }799 val removed = before - entryList.size800 if (removed > 0) persistOrRollback(rollback)801 removed802 }803804 805 suspend fun countDuplicateEntries(): Int = mutex.withLock {806 requireUnlocked()807 countDuplicateEntriesLocked()808 }809810 811812813814815 suspend fun deduplicateEntries(): Int = mutex.withLock {816 requireUnlocked()817 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")818 val active = entryList.filter { it.deletedAt == null }819 val keepIds = mutableSetOf<String>()820 active.groupBy { it.duplicateKey() }.values.forEach { group ->821 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id822 }823824 val rollback = entryList.toMutableList()825 var removed = 0826 entryList = entryList.map { entry ->827 if (entry.deletedAt == null && entry.id !in keepIds) {828 removed++829 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)830 entry.copy(deletedAt = stamp, modifiedAt = stamp)831 } else {832 entry833 }834 }.toMutableList()835 if (removed > 0) persistOrRollback(rollback)836 removed837 }838839 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }840 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }841 fun getDeletedEntries(): List<VaultEntry> =842 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }843844 845 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {846 val active = entryList.filter { it.deletedAt == null }847 if (tagFilter == null) return active848 val needle = tagFilter.trim().lowercase()849 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }850 }851852 853 fun getDistinctTags(): List<String> =854 entryList.asSequence()855 .filter { it.deletedAt == null }856 .flatMap { it.tags?.asSequence() ?: emptySequence() }857 .map { it.trim().lowercase() }858 .filter { it.isNotEmpty() }859 .distinct()860 .sorted()861 .toList()862863 864865866867868869 fun getDistinctFieldValues(field: String): List<String> {870 val select: (VaultEntry) -> String? = when (field) {871 "username" -> { e -> e.username }872 "email" -> { e -> e.email }873 else -> return emptyList()874 }875 return entryList.asSequence()876 .filter { it.deletedAt == null }877 .mapNotNull { select(it)?.trim() }878 .filter { it.isNotEmpty() }879 .groupingBy { it.lowercase() }880 .aggregate { _, acc: Pair<String, Int>?, value, _ ->881 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)882 }883 .values884 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })885 .map { it.first }886 }887888 fun getTombstones(): List<Tombstone> =889 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }890 fun getVaultVersion(): Long = store.version()891 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots892893 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {894 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false895 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false896 takeVaultSnapshot("restore")897 if (!store.writeSlotA(restoredA)) return false898 if (!store.writeSlotB(snapshot.vault_b)) return false899 store.stampLocalMutation()900 lock()901 true902 }903904 905906 907908909910911 suspend fun applyMergedRemoteEntries(912 remoteEntries: List<VaultEntry>,913 remoteTombstones: List<Tombstone>,914 ): Int = mutex.withLock {915 requireUnlocked()916 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }917 var applied = 0918 val byId = entryList.associateBy { it.id }.toMutableMap()919920 for (remote in remoteEntries) {921 val tomb = tombMap[remote.id]922 if (tomb != null && tomb >= remote.modifiedAt) continue923 val local = byId[remote.id]924 if (local == null) {925 entryList.add(remote); byId[remote.id] = remote; applied++926 } else if (remote.modifiedAt > local.modifiedAt) {927 val i = entryList.indexOfFirst { it.id == local.id }928 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }929 }930 }931932 for ((id, deletedAt) in tombMap) {933 val local = byId[id] ?: continue934 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue935 if (deletedAt < local.modifiedAt) continue936 val i = entryList.indexOfFirst { it.id == id }937 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }938 }939940 if (applied > 0) {941 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")942 publish()943 }944 applied945 }946947 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {948 _settings.value = settings949 store.writeSettings(settings)950 }951952 953954 955 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet956957 958959960961962 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {963 requireUnlocked()964 recoveryWordHash = recoveryWordsHash(words)965 recoveryWordsSet = true966 967 968 969 970 runCatching {971 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))972 }.getOrDefault(false)973 }974975 976 fun verifyRecoveryWords(words: List<String>): Boolean {977 val stored = recoveryWordHash ?: return false978 return stored == recoveryWordsHash(words)979 }980981 982983 984 fun hasRecoveryEscrow(): Boolean =985 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false986987 988989990991992993994995996997998999 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {1000 val slotA = store.loadSlotA() ?: return false1001 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false10021003 1004 1005 slotA.recoveryEscrow?.let { escrow ->1006 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1007 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1008 val keyBytes = recoveryKey.encoded1009 try {1010 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1011 val payload = VaultCodec.decode(plaintext)1012 decoyMode = false1013 entryList = payload.entries.toMutableList()1014 duressConfigured = payload.duressConfigured1015 recoveryWordHash = payload.recoveryWordHash1016 recoveryWordsSet = payload.recoveryWordsSet1017 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1018 else generateRecoverableRootKey()1019 _settings.value = store.loadSettings()1020 VaultCrypto.zero(plaintext)1021 return true1022 } catch (_: Exception) {1023 1024 } finally {1025 VaultCrypto.zero(material)1026 VaultCrypto.zero(keyBytes)1027 }1028 }10291030 1031 1032 1033 slotA.rootKeyEscrow?.let { escrow ->1034 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1035 decoyMode = false1036 entryList = mutableListOf()1037 duressConfigured = false1038 recoveryWordHash = recoveryWordsHash(words)1039 recoveryWordsSet = true1040 rootKey = rk1041 _settings.value = store.loadSettings()1042 return true1043 }1044 return false1045 }10461047 104810491050105110521053105410551056 suspend fun resetPasswordWithRecoveryWords(1057 words: List<String>,1058 newPassword: String,1059 ): Boolean = mutex.withLock {1060 if (rootKey == null) return false 1061 return try {1062 val newSalt = VaultCrypto.generateSalt()1063 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1064 val plaintext = VaultCodec.encode(currentPayload())1065 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1066 val prior = store.loadSlotA()1067 1068 1069 1070 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1071 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1072 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1073 val next = StoredVault(1074 version = prior?.version ?: 1,1075 salt = b64(newSalt),1076 blob = blob,1077 recoveryEscrow = freshRecovery,1078 rootKeyEscrow = freshRootKey,1079 )1080 if (!store.writeSlotA(next)) return false1081 writeMatchingNoiseSlotB(next)1082 store.stampLocalMutation()1083 derivedKey?.let(VaultCrypto::zero)1084 derivedKey = newKey1085 VaultCrypto.zero(plaintext)1086 publish()1087 _isUnlocked.value = true1088 true1089 } catch (_: Exception) {1090 false1091 }1092 }10931094 109510961097109810991100 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1101 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1102 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1103 val salt = VaultCrypto.generateSalt()1104 val recoveryKey = deriveRecoveryKey(material, salt)1105 val keyBytes = recoveryKey.encoded1106 val plaintext = VaultCodec.encode(currentPayload())1107 return try {1108 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1109 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1110 } finally {1111 VaultCrypto.zero(material)1112 VaultCrypto.zero(keyBytes)1113 VaultCrypto.zero(plaintext)1114 }1115 }11161117 11181119112011211122112311241125 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1126 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1127 val rk = rootKey ?: return null1128 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1129 val salt = VaultCrypto.generateSalt()1130 val recoveryKey = deriveRecoveryKey(material, salt)1131 val keyBytes = recoveryKey.encoded1132 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1133 return try {1134 1135 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1136 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1137 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1138 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1139 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1140 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1141 } finally {1142 VaultCrypto.zero(material)1143 VaultCrypto.zero(keyBytes)1144 VaultCrypto.zero(plaintext)1145 }1146 }11471148 1149 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1150 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1151 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1152 val keyBytes = recoveryKey.encoded1153 return try {1154 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1155 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1156 hexToRootKey(String(pt, Charsets.US_ASCII))1157 } catch (_: Exception) {1158 null1159 } finally {1160 VaultCrypto.zero(material)1161 VaultCrypto.zero(keyBytes)1162 }1163 }11641165 116611671168116911701171 private fun persistSlotAWithEscrows(1172 recoveryEscrow: RecoveryEscrow?,1173 rootKeyEscrow: RootKeyEscrow?,1174 ): Boolean {1175 val key = derivedKey ?: throw IllegalStateException("vault_locked")1176 if (decoyMode) return false1177 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1178 val plaintext = VaultCodec.encode(currentPayload())1179 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1180 val next = prior.copy(1181 blob = blob,1182 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1183 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1184 )1185 if (!store.writeSlotA(next)) return false1186 try {1187 writeMatchingNoiseSlotB(next)1188 } catch (e: Exception) {1189 store.writeSlotA(prior)1190 throw e1191 }1192 return true1193 }11941195 11961197 private fun requireUnlocked() {1198 if (derivedKey == null) throw IllegalStateException("vault_locked")1199 }12001201 private fun countDuplicateEntriesLocked(): Int {1202 val seen = mutableMapOf<String, VaultEntry>()1203 var duplicates = 01204 for (entry in entryList.filter { it.deletedAt == null }) {1205 val key = entry.duplicateKey()1206 val existing = seen[key]1207 if (existing == null) {1208 seen[key] = entry1209 } else {1210 duplicates++1211 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1212 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1213 if (entryStamp > existingStamp) seen[key] = entry1214 }1215 }1216 return duplicates1217 }12181219 private fun VaultEntry.duplicateKey(): String {1220 if (type == "note") return "note|${title.lowercase()}"1221 val passkeyData = passkey1222 if (passkeyData != null) {1223 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1224 }1225 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1226 }12271228 private fun takeVaultSnapshot(reason: String) {1229 runCatching {1230 val slotA = store.loadSlotA() ?: return1231 val vaultA = VaultCodec.encodeSlot(slotA)1232 val vaultB = store.loadSlotB() ?: slotA.blob1233 val history = store.loadVaultHistory()1234 val latest = history.snapshots.firstOrNull()1235 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1236 val entryCount = if (derivedKey != null && !decoyMode) {1237 bucketEntryCount(entryList.count { it.deletedAt == null })1238 } else {1239 null1240 }1241 val snapshot = VaultSnapshot(1242 id = UUID.randomUUID().toString(),1243 savedAt = System.currentTimeMillis(),1244 reason = reason,1245 vault_a = vaultA,1246 vault_b = vaultB,1247 entryCount = entryCount,1248 )1249 store.writeVaultHistory(1250 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1251 )1252 }1253 }12541255 private fun bucketEntryCount(n: Int): Int = when {1256 n <= 0 -> 01257 n <= 10 -> 101258 n <= 25 -> 251259 n <= 50 -> 501260 n <= 100 -> 1001261 n <= 250 -> 2501262 n <= 500 -> 5001263 else -> ((n + 499) / 500) * 5001264 }12651266 12671268126912701271 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1272 if (!entry.isCard()) return entry1273 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1274 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1275 }12761277 127812791280128112821283 private fun autoPurgeExpiredTrash() {1284 val days = _settings.value.trashRetentionDays ?: return1285 if (days <= 0) return1286 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1287 val before = entryList.size1288 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1289 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1290 }12911292 1293 private fun currentPayload(): VaultPayload =1294 VaultPayload(1295 entries = entryList.toList(),1296 duressConfigured = duressConfigured,1297 rootKeyB64 = rootKey?.let(::b64) ?: "",1298 recoveryWordHash = recoveryWordHash,1299 recoveryWordsSet = recoveryWordsSet,1300 )13011302 13031304130513061307 private fun ensureRootKey(payload: VaultPayload) {1308 if (payload.rootKeyB64.isNotBlank()) {1309 rootKey = unb64(payload.rootKeyB64)1310 return1311 }1312 rootKey = generateRecoverableRootKey()1313 runCatching { persistSlotA(bumpVersion = false) }1314 }13151316 private fun publish() {1317 _entries.value = entryList.filter { it.deletedAt == null }1318 }13191320 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1321 val plaintext = VaultCodec.encode(currentPayload())1322 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1323 return StoredVault(version = 1, salt = b64(salt), blob = blob)1324 }13251326 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1327 1328 1329 1330 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1331 val noise = ByteArray(approxBytes).also(rng::nextBytes)1332 store.writeSlotB(b64(noise))1333 }13341335 1336 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1337 val key = derivedKey ?: throw IllegalStateException("vault_locked")1338 1339 if (decoyMode) {1340 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1341 val plaintext = VaultCodec.encode(currentPayload())1342 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1343 return store.writeDecoySlot(prior.copy(blob = blob))1344 }1345 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1346 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1347 val plaintext = VaultCodec.encode(currentPayload())1348 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1349 val next = prior.copy(blob = blob)1350 if (!store.writeSlotA(next)) return false1351 1352 1353 try {1354 writeMatchingNoiseSlotB(next)1355 if (bumpVersion) store.stampLocalMutation()1356 } catch (e: Exception) {1357 store.writeSlotA(prior)1358 throw e1359 }1360 return true1361 }13621363 1364 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1365 try {1366 if (!persistSlotA(bumpVersion = true)) {1367 entryList = rollback1368 throw IllegalStateException("storage_write_failed")1369 }1370 } catch (e: Exception) {1371 entryList = rollback1372 throw e1373 }1374 publish()1375 }1376}