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 (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {216 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")217 }218 var adoptedRootKey: ByteArray? = null219 var nextDerivedKey: ByteArray? = null220 var sessionRootKey: ByteArray? = null221 return try {222 adoptedRootKey = hexToRootKey(rootKeyHex)223 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {224 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")225 }226 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)227 val salt = VaultCrypto.generateSalt()228 nextDerivedKey = VaultCrypto.deriveKey(password, salt)229 sessionRootKey = adoptedRootKey.copyOf()230 val payload = VaultPayload(231 entries = emptyList(),232 duressConfigured = false,233 rootKeyB64 = b64(sessionRootKey),234 recoveryWordHash = null,235 recoveryWordsSet = false,236 )237 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))238 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)239 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")240 writeMatchingNoiseSlotB(slotA)241 store.stampLocalMutation()242 store.writeSettings(_settings.value)243244 val previousDerivedKey = derivedKey245 val previousRootKey = rootKey246 derivedKey = nextDerivedKey247 rootKey = sessionRootKey248 nextDerivedKey = null249 sessionRootKey = null250 entryList = mutableListOf()251 duressConfigured = false252 recoveryWordHash = null253 recoveryWordsSet = false254 decoyMode = false255 publish()256 _isUnlocked.value = true257 previousDerivedKey?.let(VaultCrypto::zero)258 previousRootKey?.let(VaultCrypto::zero)259 AdoptVaultResult.Success(vaultId)260 } catch (e: Exception) {261 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")262 } finally {263 adoptedRootKey?.let(VaultCrypto::zero)264 nextDerivedKey?.let(VaultCrypto::zero)265 sessionRootKey?.let(VaultCrypto::zero)266 }267 }268269 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }270271 272 private fun unlockLocked(password: String): UnlockResult {273 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")274 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))275 val payload = try {276 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))277 } catch (_: Exception) {278 VaultCrypto.zero(key)279 280 281 282 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {283 val legacy = tryLegacyRawUnlock(password, slotA)284 if (legacy != null) return legacy285 }286 return UnlockResult.WrongPassword287 }288 return adoptSlotA(key, payload)289 }290291 292293294295296297 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {298 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))299 val payload = try {300 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))301 } catch (_: Exception) {302 VaultCrypto.zero(rawKey)303 return null304 }305 306 307 val newSalt = VaultCrypto.generateSalt()308 val newKey = VaultCrypto.deriveKey(password, newSalt)309 val healed = try {310 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))311 val next = StoredVault(312 version = slotA.version,313 salt = b64(newSalt),314 blob = blob,315 recoveryEscrow = slotA.recoveryEscrow,316 rootKeyEscrow = slotA.rootKeyEscrow,317 )318 if (store.writeSlotA(next)) {319 writeMatchingNoiseSlotB(next)320 store.stampLocalMutation()321 true322 } else {323 false324 }325 } catch (_: Exception) {326 false327 }328 return if (healed) {329 VaultCrypto.zero(rawKey)330 adoptSlotA(newKey, payload) 331 } else {332 VaultCrypto.zero(newKey)333 adoptSlotA(rawKey, payload) 334 }335 }336337 338 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {339 return try {340 derivedKey = key341 decoyMode = false342 entryList = payload.entries.toMutableList()343 duressConfigured = payload.duressConfigured344 recoveryWordHash = payload.recoveryWordHash345 recoveryWordsSet = payload.recoveryWordsSet346 _settings.value = store.loadSettings()347 ensureRootKey(payload)348 autoPurgeExpiredTrash()349 publish()350 _isUnlocked.value = true351 UnlockResult.Success352 } catch (e: Exception) {353 UnlockResult.Error(e.message ?: "unlock_failed")354 }355 }356357 358359 360361362363364365 class SlotAUnlock internal constructor(366 internal val key: ByteArray,367 internal val payload: VaultPayload,368 )369370 371372373374375376 fun tryUnlockSlotA(password: String): SlotAUnlock? {377 val slotA = store.loadSlotA() ?: return null378 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))379 return try {380 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))381 SlotAUnlock(key, payload)382 } catch (_: Exception) {383 VaultCrypto.zero(key)384 null385 }386 }387388 389 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {390 adoptSlotA(unlock.key, unlock.payload)391 }392393 394 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)395396 397398 399400401402403 class DuressUnlock internal constructor(404 internal val key: ByteArray,405 internal val payload: VaultPayload,406 )407408 409410411412413414415 fun tryUnlockDuress(password: String): DuressUnlock? {416 val slot = store.loadDecoySlot() ?: return null417 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))418 return try {419 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))420 DuressUnlock(key, payload)421 } catch (_: Exception) {422 VaultCrypto.zero(key)423 null424 }425 }426427 428 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {429 return try {430 derivedKey = unlock.key431 decoyMode = true432 entryList = unlock.payload.entries.toMutableList()433 duressConfigured = false434 recoveryWordHash = unlock.payload.recoveryWordHash435 recoveryWordsSet = unlock.payload.recoveryWordsSet436 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)437 else VaultCrypto.generateRootKey()438 _settings.value = store.loadSettings()439 publish()440 _isUnlocked.value = true441 UnlockResult.Success442 } catch (e: Exception) {443 UnlockResult.Error(e.message ?: "decoy_unlock_failed")444 }445 }446447 448 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)449450 451452453454455456 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {457 if (derivedKey == null || decoyMode) return false458 459 460 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }461 return try {462 val salt = VaultCrypto.generateSalt()463 val key = VaultCrypto.deriveKey(duressPassphrase, salt)464 465 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))466 val plaintext = VaultCodec.encode(decoyPayload)467 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))468 VaultCrypto.zero(key)469 VaultCrypto.zero(plaintext)470 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))471 if (ok) {472 duressConfigured = true473 runCatching { persistSlotA(bumpVersion = false) }474 }475 ok476 } catch (_: Exception) {477 false478 }479 }480481 482 suspend fun clearDuress(): Boolean = mutex.withLock {483 store.clearDecoySlot()484 if (derivedKey != null && !decoyMode) {485 duressConfigured = false486 runCatching { persistSlotA(bumpVersion = false) }487 }488 true489 }490491 492 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {493 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")494 return try {495 val payload = VaultCodec.decode(496 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),497 )498 derivedKey = key.copyOf()499 decoyMode = false500 entryList = payload.entries.toMutableList()501 duressConfigured = payload.duressConfigured502 recoveryWordHash = payload.recoveryWordHash503 recoveryWordsSet = payload.recoveryWordsSet504 _settings.value = store.loadSettings()505 ensureRootKey(payload)506 autoPurgeExpiredTrash()507 publish()508 _isUnlocked.value = true509 UnlockResult.Success510 } catch (_: Exception) {511 UnlockResult.WrongPassword512 }513 }514515 fun lock() {516 derivedKey?.let(VaultCrypto::zero)517 rootKey?.let(VaultCrypto::zero)518 derivedKey = null519 rootKey = null520 entryList = mutableListOf()521 duressConfigured = false522 recoveryWordHash = null523 recoveryWordsSet = false524 decoyMode = false525 _entries.value = emptyList()526 _isUnlocked.value = false527 }528529 @Volatile private var suppressNextBackgroundLock = false530531 532533534535536537538539540 fun expectTransientBackground() {541 suppressNextBackgroundLock = true542 }543544 545 fun consumeTransientBackgroundSuppression(): Boolean {546 val suppressed = suppressNextBackgroundLock547 suppressNextBackgroundLock = false548 return suppressed549 }550551 552553 554555556557558 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()559560 561 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }562563 564565566567568 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {569 val key = derivedKey ?: return false570 if (decoyMode) return false571 val priorSlot = store.loadSlotA() ?: return false572 val priorRootKey = rootKey ?: return false573 val priorRecoveryHash = recoveryWordHash574 val priorRecoverySet = recoveryWordsSet575 val nextRootKey = generateRecoverableRootKey()576 val nextWords = rootKeyToWords(nextRootKey)577578 try {579 takeVaultSnapshot("local_save")580 rootKey = nextRootKey581 recoveryWordHash = recoveryWordsHash(nextWords)582 recoveryWordsSet = false583584 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)585 ?: throw IllegalStateException("recovery_escrow_failed")586 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)587 ?: throw IllegalStateException("root_escrow_failed")588 val plaintext = VaultCodec.encode(currentPayload())589 val blob = try {590 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))591 } finally {592 VaultCrypto.zero(plaintext)593 }594 val nextSlot = priorSlot.copy(595 blob = blob,596 recoveryEscrow = nextRecoveryEscrow,597 rootKeyEscrow = nextRootKeyEscrow,598 )599 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")600 try {601 writeMatchingNoiseSlotB(nextSlot)602 store.stampLocalMutation()603 } catch (e: Exception) {604 store.writeSlotA(priorSlot)605 throw e606 }607 VaultCrypto.zero(priorRootKey)608 publish()609 true610 } catch (_: Exception) {611 rootKey = priorRootKey612 recoveryWordHash = priorRecoveryHash613 recoveryWordsSet = priorRecoverySet614 VaultCrypto.zero(nextRootKey)615 store.writeSlotA(priorSlot)616 false617 }618 }619620 621622623624625 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()626627 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {628 val slotA = store.loadSlotA() ?: return false629 return try {630 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))631 val payload = VaultCodec.decode(632 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),633 )634 val newSalt = VaultCrypto.generateSalt()635 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)636 637 val plaintext = VaultCodec.encode(payload)638 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))639 640 641 642 643 644 645 646 val next = StoredVault(647 version = slotA.version,648 salt = b64(newSalt),649 blob = blob,650 recoveryEscrow = slotA.recoveryEscrow,651 rootKeyEscrow = slotA.rootKeyEscrow,652 )653 if (!store.writeSlotA(next)) return false654 writeMatchingNoiseSlotB(next)655 store.stampLocalMutation()656 VaultCrypto.zero(oldKey)657 derivedKey?.let(VaultCrypto::zero)658 derivedKey = newKey659 true660 } catch (_: Exception) {661 false662 }663 }664665 666667668669 suspend fun destroyVault() = mutex.withLock {670 lock()671 store.clearVault()672 store.clearDecoySlot()673 }674675 676677 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {678 requireUnlocked()679 val stamp = VaultCrypto.monotonicEntryTimestamp()680 val now = System.currentTimeMillis()681 val entry = withDerivedLast4(draft.copy(682 id = draft.id.ifBlank { UUID.randomUUID().toString() },683 createdAt = if (draft.createdAt > 0) draft.createdAt else now,684 modifiedAt = stamp,685 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,686 ))687 val rollback = entryList.toMutableList()688 entryList.add(entry)689 persistOrRollback(rollback)690 entry691 }692693 694695696697698699700701702 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {703 requireUnlocked()704 if (drafts.isEmpty()) return 0705 val rollback = entryList.toMutableList()706 val now = System.currentTimeMillis()707 for (draft in drafts) {708 val stamp = VaultCrypto.monotonicEntryTimestamp()709 entryList.add(710 withDerivedLast4(711 draft.copy(712 id = draft.id.ifBlank { UUID.randomUUID().toString() },713 createdAt = if (draft.createdAt > 0) draft.createdAt else now,714 modifiedAt = stamp,715 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,716 ),717 ),718 )719 }720 persistOrRollback(rollback)721 return drafts.size722 }723724 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {725 requireUnlocked()726 val idx = entryList.indexOfFirst { it.id == patch.id }727 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")728 val current = entryList[idx]729 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)730 731 val passwordChanged = patch.password != null && patch.password != current.password732 733 734 735 736 737 738 val priorPassword = current.password739 val shouldCapture = passwordChanged &&740 !priorPassword.isNullOrEmpty() &&741 current.passwordHistory?.firstOrNull()?.password != priorPassword742 val nextHistory = if (shouldCapture) {743 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +744 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)745 } else {746 current.passwordHistory747 }748 val updated = withDerivedLast4(patch.copy(749 createdAt = current.createdAt,750 modifiedAt = stamp,751 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,752 passwordHistory = nextHistory,753 ))754 val rollback = entryList.toMutableList()755 entryList[idx] = updated756 persistOrRollback(rollback)757 updated758 }759760 761 suspend fun deleteEntry(id: String) = mutex.withLock {762 requireUnlocked()763 val idx = entryList.indexOfFirst { it.id == id }764 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")765 val current = entryList[idx]766 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)767 val rollback = entryList.toMutableList()768 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)769 persistOrRollback(rollback)770 }771772 suspend fun restoreEntry(id: String) = mutex.withLock {773 requireUnlocked()774 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }775 if (idx == -1) throw IllegalStateException("deleted entry not found")776 val current = entryList[idx]777 val rollback = entryList.toMutableList()778 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))779 persistOrRollback(rollback)780 }781782 783 suspend fun purgeEntry(id: String) = mutex.withLock {784 requireUnlocked()785 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }786 if (idx == -1) throw IllegalStateException("deleted entry not found")787 val rollback = entryList.toMutableList()788 entryList.removeAt(idx)789 persistOrRollback(rollback)790 }791792 793 suspend fun purgeDeletedEntries(): Int = mutex.withLock {794 requireUnlocked()795 val rollback = entryList.toMutableList()796 val before = entryList.size797 entryList.removeAll { it.deletedAt != null }798 val removed = before - entryList.size799 if (removed > 0) persistOrRollback(rollback)800 removed801 }802803 804 suspend fun countDuplicateEntries(): Int = mutex.withLock {805 requireUnlocked()806 countDuplicateEntriesLocked()807 }808809 810811812813814 suspend fun deduplicateEntries(): Int = mutex.withLock {815 requireUnlocked()816 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")817 val active = entryList.filter { it.deletedAt == null }818 val keepIds = mutableSetOf<String>()819 active.groupBy { it.duplicateKey() }.values.forEach { group ->820 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id821 }822823 val rollback = entryList.toMutableList()824 var removed = 0825 entryList = entryList.map { entry ->826 if (entry.deletedAt == null && entry.id !in keepIds) {827 removed++828 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)829 entry.copy(deletedAt = stamp, modifiedAt = stamp)830 } else {831 entry832 }833 }.toMutableList()834 if (removed > 0) persistOrRollback(rollback)835 removed836 }837838 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }839 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }840 fun getDeletedEntries(): List<VaultEntry> =841 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }842843 844 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {845 val active = entryList.filter { it.deletedAt == null }846 if (tagFilter == null) return active847 val needle = tagFilter.trim().lowercase()848 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }849 }850851 852 fun getDistinctTags(): List<String> =853 entryList.asSequence()854 .filter { it.deletedAt == null }855 .flatMap { it.tags?.asSequence() ?: emptySequence() }856 .map { it.trim().lowercase() }857 .filter { it.isNotEmpty() }858 .distinct()859 .sorted()860 .toList()861862 863864865866867868 fun getDistinctFieldValues(field: String): List<String> {869 val select: (VaultEntry) -> String? = when (field) {870 "username" -> { e -> e.username }871 "email" -> { e -> e.email }872 else -> return emptyList()873 }874 return entryList.asSequence()875 .filter { it.deletedAt == null }876 .mapNotNull { select(it)?.trim() }877 .filter { it.isNotEmpty() }878 .groupingBy { it.lowercase() }879 .aggregate { _, acc: Pair<String, Int>?, value, _ ->880 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)881 }882 .values883 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })884 .map { it.first }885 }886887 fun getTombstones(): List<Tombstone> =888 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }889 fun getVaultVersion(): Long = store.version()890 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots891892 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {893 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false894 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false895 takeVaultSnapshot("restore")896 if (!store.writeSlotA(restoredA)) return false897 if (!store.writeSlotB(snapshot.vault_b)) return false898 store.stampLocalMutation()899 lock()900 true901 }902903 904905 906907908909910 suspend fun applyMergedRemoteEntries(911 remoteEntries: List<VaultEntry>,912 remoteTombstones: List<Tombstone>,913 ): Int = mutex.withLock {914 requireUnlocked()915 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }916 var applied = 0917 val byId = entryList.associateBy { it.id }.toMutableMap()918919 for (remote in remoteEntries) {920 val tomb = tombMap[remote.id]921 if (tomb != null && tomb >= remote.modifiedAt) continue922 val local = byId[remote.id]923 if (local == null) {924 entryList.add(remote); byId[remote.id] = remote; applied++925 } else if (remote.modifiedAt > local.modifiedAt) {926 val i = entryList.indexOfFirst { it.id == local.id }927 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }928 }929 }930931 for ((id, deletedAt) in tombMap) {932 val local = byId[id] ?: continue933 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue934 if (deletedAt < local.modifiedAt) continue935 val i = entryList.indexOfFirst { it.id == id }936 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }937 }938939 if (applied > 0) {940 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")941 publish()942 }943 applied944 }945946 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {947 _settings.value = settings948 store.writeSettings(settings)949 }950951 952953 954 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet955956 957958959960961 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {962 requireUnlocked()963 recoveryWordHash = recoveryWordsHash(words)964 recoveryWordsSet = true965 966 967 968 969 runCatching {970 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))971 }.getOrDefault(false)972 }973974 975 fun verifyRecoveryWords(words: List<String>): Boolean {976 val stored = recoveryWordHash ?: return false977 return stored == recoveryWordsHash(words)978 }979980 981982 983 fun hasRecoveryEscrow(): Boolean =984 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false985986 987988989990991992993994995996997998 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {999 val slotA = store.loadSlotA() ?: return false1000 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false10011002 1003 1004 slotA.recoveryEscrow?.let { escrow ->1005 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let1006 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1007 val keyBytes = recoveryKey.encoded1008 try {1009 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)1010 val payload = VaultCodec.decode(plaintext)1011 decoyMode = false1012 entryList = payload.entries.toMutableList()1013 duressConfigured = payload.duressConfigured1014 recoveryWordHash = payload.recoveryWordHash1015 recoveryWordsSet = payload.recoveryWordsSet1016 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)1017 else generateRecoverableRootKey()1018 _settings.value = store.loadSettings()1019 VaultCrypto.zero(plaintext)1020 return true1021 } catch (_: Exception) {1022 1023 } finally {1024 VaultCrypto.zero(material)1025 VaultCrypto.zero(keyBytes)1026 }1027 }10281029 1030 1031 1032 slotA.rootKeyEscrow?.let { escrow ->1033 val rk = unsealRootKeyEscrow(words, escrow) ?: return false1034 decoyMode = false1035 entryList = mutableListOf()1036 duressConfigured = false1037 recoveryWordHash = recoveryWordsHash(words)1038 recoveryWordsSet = true1039 rootKey = rk1040 _settings.value = store.loadSettings()1041 return true1042 }1043 return false1044 }10451046 104710481049105010511052105310541055 suspend fun resetPasswordWithRecoveryWords(1056 words: List<String>,1057 newPassword: String,1058 ): Boolean = mutex.withLock {1059 if (rootKey == null) return false 1060 return try {1061 val newSalt = VaultCrypto.generateSalt()1062 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1063 val plaintext = VaultCodec.encode(currentPayload())1064 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1065 val prior = store.loadSlotA()1066 1067 1068 1069 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1070 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1071 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1072 val next = StoredVault(1073 version = prior?.version ?: 1,1074 salt = b64(newSalt),1075 blob = blob,1076 recoveryEscrow = freshRecovery,1077 rootKeyEscrow = freshRootKey,1078 )1079 if (!store.writeSlotA(next)) return false1080 writeMatchingNoiseSlotB(next)1081 store.stampLocalMutation()1082 derivedKey?.let(VaultCrypto::zero)1083 derivedKey = newKey1084 VaultCrypto.zero(plaintext)1085 publish()1086 _isUnlocked.value = true1087 true1088 } catch (_: Exception) {1089 false1090 }1091 }10921093 109410951096109710981099 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1100 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1101 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1102 val salt = VaultCrypto.generateSalt()1103 val recoveryKey = deriveRecoveryKey(material, salt)1104 val keyBytes = recoveryKey.encoded1105 val plaintext = VaultCodec.encode(currentPayload())1106 return try {1107 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1108 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1109 } finally {1110 VaultCrypto.zero(material)1111 VaultCrypto.zero(keyBytes)1112 VaultCrypto.zero(plaintext)1113 }1114 }11151116 11171118111911201121112211231124 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1125 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1126 val rk = rootKey ?: return null1127 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1128 val salt = VaultCrypto.generateSalt()1129 val recoveryKey = deriveRecoveryKey(material, salt)1130 val keyBytes = recoveryKey.encoded1131 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1132 return try {1133 1134 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1135 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1136 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1137 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1138 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1139 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1140 } finally {1141 VaultCrypto.zero(material)1142 VaultCrypto.zero(keyBytes)1143 VaultCrypto.zero(plaintext)1144 }1145 }11461147 1148 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1149 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1150 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1151 val keyBytes = recoveryKey.encoded1152 return try {1153 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1154 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1155 hexToRootKey(String(pt, Charsets.US_ASCII))1156 } catch (_: Exception) {1157 null1158 } finally {1159 VaultCrypto.zero(material)1160 VaultCrypto.zero(keyBytes)1161 }1162 }11631164 116511661167116811691170 private fun persistSlotAWithEscrows(1171 recoveryEscrow: RecoveryEscrow?,1172 rootKeyEscrow: RootKeyEscrow?,1173 ): Boolean {1174 val key = derivedKey ?: throw IllegalStateException("vault_locked")1175 if (decoyMode) return false1176 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1177 val plaintext = VaultCodec.encode(currentPayload())1178 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1179 val next = prior.copy(1180 blob = blob,1181 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1182 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1183 )1184 if (!store.writeSlotA(next)) return false1185 try {1186 writeMatchingNoiseSlotB(next)1187 } catch (e: Exception) {1188 store.writeSlotA(prior)1189 throw e1190 }1191 return true1192 }11931194 11951196 private fun requireUnlocked() {1197 if (derivedKey == null) throw IllegalStateException("vault_locked")1198 }11991200 private fun countDuplicateEntriesLocked(): Int {1201 val seen = mutableMapOf<String, VaultEntry>()1202 var duplicates = 01203 for (entry in entryList.filter { it.deletedAt == null }) {1204 val key = entry.duplicateKey()1205 val existing = seen[key]1206 if (existing == null) {1207 seen[key] = entry1208 } else {1209 duplicates++1210 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1211 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1212 if (entryStamp > existingStamp) seen[key] = entry1213 }1214 }1215 return duplicates1216 }12171218 private fun VaultEntry.duplicateKey(): String {1219 if (type == "note") return "note|${title.lowercase()}"1220 val passkeyData = passkey1221 if (passkeyData != null) {1222 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1223 }1224 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1225 }12261227 private fun takeVaultSnapshot(reason: String) {1228 runCatching {1229 val slotA = store.loadSlotA() ?: return1230 val vaultA = VaultCodec.encodeSlot(slotA)1231 val vaultB = store.loadSlotB() ?: slotA.blob1232 val history = store.loadVaultHistory()1233 val latest = history.snapshots.firstOrNull()1234 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1235 val entryCount = if (derivedKey != null && !decoyMode) {1236 bucketEntryCount(entryList.count { it.deletedAt == null })1237 } else {1238 null1239 }1240 val snapshot = VaultSnapshot(1241 id = UUID.randomUUID().toString(),1242 savedAt = System.currentTimeMillis(),1243 reason = reason,1244 vault_a = vaultA,1245 vault_b = vaultB,1246 entryCount = entryCount,1247 )1248 store.writeVaultHistory(1249 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1250 )1251 }1252 }12531254 private fun bucketEntryCount(n: Int): Int = when {1255 n <= 0 -> 01256 n <= 10 -> 101257 n <= 25 -> 251258 n <= 50 -> 501259 n <= 100 -> 1001260 n <= 250 -> 2501261 n <= 500 -> 5001262 else -> ((n + 499) / 500) * 5001263 }12641265 12661267126812691270 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1271 if (!entry.isCard()) return entry1272 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1273 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1274 }12751276 127712781279128012811282 private fun autoPurgeExpiredTrash() {1283 val days = _settings.value.trashRetentionDays ?: return1284 if (days <= 0) return1285 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1286 val before = entryList.size1287 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1288 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1289 }12901291 1292 private fun currentPayload(): VaultPayload =1293 VaultPayload(1294 entries = entryList.toList(),1295 duressConfigured = duressConfigured,1296 rootKeyB64 = rootKey?.let(::b64) ?: "",1297 recoveryWordHash = recoveryWordHash,1298 recoveryWordsSet = recoveryWordsSet,1299 )13001301 13021303130413051306 private fun ensureRootKey(payload: VaultPayload) {1307 if (payload.rootKeyB64.isNotBlank()) {1308 rootKey = unb64(payload.rootKeyB64)1309 return1310 }1311 rootKey = generateRecoverableRootKey()1312 runCatching { persistSlotA(bumpVersion = false) }1313 }13141315 private fun publish() {1316 _entries.value = entryList.filter { it.deletedAt == null }1317 }13181319 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1320 val plaintext = VaultCodec.encode(currentPayload())1321 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1322 return StoredVault(version = 1, salt = b64(salt), blob = blob)1323 }13241325 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1326 1327 1328 1329 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1330 val noise = ByteArray(approxBytes).also(rng::nextBytes)1331 store.writeSlotB(b64(noise))1332 }13331334 1335 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1336 val key = derivedKey ?: throw IllegalStateException("vault_locked")1337 1338 if (decoyMode) {1339 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1340 val plaintext = VaultCodec.encode(currentPayload())1341 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1342 return store.writeDecoySlot(prior.copy(blob = blob))1343 }1344 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1345 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1346 val plaintext = VaultCodec.encode(currentPayload())1347 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1348 val next = prior.copy(blob = blob)1349 if (!store.writeSlotA(next)) return false1350 1351 1352 try {1353 writeMatchingNoiseSlotB(next)1354 if (bumpVersion) store.stampLocalMutation()1355 } catch (e: Exception) {1356 store.writeSlotA(prior)1357 throw e1358 }1359 return true1360 }13611362 1363 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1364 try {1365 if (!persistSlotA(bumpVersion = true)) {1366 entryList = rollback1367 throw IllegalStateException("storage_write_failed")1368 }1369 } catch (e: Exception) {1370 entryList = rollback1371 throw e1372 }1373 publish()1374 }1375}