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 kotlinx.serialization.json.JsonElement12import kotlinx.serialization.json.JsonNull13import kotlinx.serialization.json.JsonObject14import kotlinx.serialization.json.JsonPrimitive15import java.util.Base6416import java.util.UUID17import javax.inject.Inject18import javax.inject.Singleton192021private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2223private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)24private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)252627private const val PASSWORD_HISTORY_MAX = 5028private const val VAULT_HISTORY_MAX = 52930sealed interface UnlockResult {31 data object Success : UnlockResult32 data object WrongPassword : UnlockResult33 data class Error(val reason: String) : UnlockResult34}3536sealed interface CreateVaultResult {37 data object Success : CreateVaultResult38 data class Error(val reason: String) : CreateVaultResult39}4041sealed interface AdoptVaultResult {42 data class Success(val vaultId: String) : AdoptVaultResult43 data class Error(val reason: String) : AdoptVaultResult44}454647484950515253545556575859@Singleton60class VaultRepository @Inject constructor(61 private val store: VaultStorage,62) {63 private val mutex = Mutex()64 private val rng = SecureRandom()6566 private var derivedKey: ByteArray? = null67 private var rootKey: ByteArray? = null68 private var entryList: MutableList<VaultEntry> = mutableListOf()69 private var duressConfigured: Boolean = false70 private var recoveryWordHash: String? = null71 private var recoveryWordsSet: Boolean = false72 73 private var decoyMode: Boolean = false7475 private val _isUnlocked = MutableStateFlow(false)76 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7778 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())79 80 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8182 private val _settings = MutableStateFlow(VaultSettings())83 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8485 fun hasVault(): Boolean = store.hasVault()8687 8889 9091929394 fun codexVaultBlobBase64(): String? {95 val slotA = store.loadSlotA() ?: return null96 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""97 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))98 }99100 101102103104105 106 private fun JsonElement?.asSlotJson(): String? = when {107 this == null || this is JsonNull -> null108 this is JsonPrimitive && isString -> content109 else -> toString()110 }111112 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {113 return try {114 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)115 116 117 118 119 120 121 122 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject123 ?: return UnlockResult.Error("invalid_codex_blob")124 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")125 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")126 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")127 128 129 val slotB = wrapper["b"].asSlotJson()130 if (slotB != null) {131 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))132 } else {133 writeMatchingNoiseSlotB(slotA)134 }135 store.stampLocalMutation()136 unlockLocked(password)137 } catch (e: Exception) {138 UnlockResult.Error(e.message ?: "restore_failed")139 }140 }141142 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {143 return try {144 val salt = VaultCrypto.generateSalt()145 val key = VaultCrypto.deriveKey(password, salt)146 entryList = mutableListOf()147 duressConfigured = false148 recoveryWordHash = null149 recoveryWordsSet = false150 decoyMode = false151 rootKey = generateRecoverableRootKey()152 val slotA = sealSlotA(key, salt)153 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")154 writeMatchingNoiseSlotB(slotA)155 store.stampLocalMutation()156 store.writeSettings(_settings.value)157 derivedKey = key158 publish()159 _isUnlocked.value = true160 CreateVaultResult.Success161 } catch (e: Exception) {162 CreateVaultResult.Error(e.message ?: "create_failed")163 }164 }165166 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {167 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")168 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {169 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")170 }171 var adoptedRootKey: ByteArray? = null172 var nextDerivedKey: ByteArray? = null173 var sessionRootKey: ByteArray? = null174 return try {175 adoptedRootKey = hexToRootKey(rootKeyHex)176 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {177 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")178 }179 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)180 val salt = VaultCrypto.generateSalt()181 nextDerivedKey = VaultCrypto.deriveKey(password, salt)182 sessionRootKey = adoptedRootKey.copyOf()183 val payload = VaultPayload(184 entries = emptyList(),185 duressConfigured = false,186 rootKeyB64 = b64(sessionRootKey),187 recoveryWordHash = null,188 recoveryWordsSet = false,189 )190 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))191 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)192 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")193 writeMatchingNoiseSlotB(slotA)194 store.stampLocalMutation()195 store.writeSettings(_settings.value)196197 val previousDerivedKey = derivedKey198 val previousRootKey = rootKey199 derivedKey = nextDerivedKey200 rootKey = sessionRootKey201 nextDerivedKey = null202 sessionRootKey = null203 entryList = mutableListOf()204 duressConfigured = false205 recoveryWordHash = null206 recoveryWordsSet = false207 decoyMode = false208 publish()209 _isUnlocked.value = true210 previousDerivedKey?.let(VaultCrypto::zero)211 previousRootKey?.let(VaultCrypto::zero)212 AdoptVaultResult.Success(vaultId)213 } catch (e: Exception) {214 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")215 } finally {216 adoptedRootKey?.let(VaultCrypto::zero)217 nextDerivedKey?.let(VaultCrypto::zero)218 sessionRootKey?.let(VaultCrypto::zero)219 }220 }221222 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }223224 225 private fun unlockLocked(password: String): UnlockResult {226 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")227 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))228 val payload = try {229 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))230 } catch (_: Exception) {231 VaultCrypto.zero(key)232 233 234 235 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {236 val legacy = tryLegacyRawUnlock(password, slotA)237 if (legacy != null) return legacy238 }239 return UnlockResult.WrongPassword240 }241 return adoptSlotA(key, payload)242 }243244 245246247248249250 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {251 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))252 val payload = try {253 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))254 } catch (_: Exception) {255 VaultCrypto.zero(rawKey)256 return null257 }258 259 260 val newSalt = VaultCrypto.generateSalt()261 val newKey = VaultCrypto.deriveKey(password, newSalt)262 val healed = try {263 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))264 val next = StoredVault(265 version = slotA.version,266 salt = b64(newSalt),267 blob = blob,268 recoveryEscrow = slotA.recoveryEscrow,269 rootKeyEscrow = slotA.rootKeyEscrow,270 )271 if (store.writeSlotA(next)) {272 writeMatchingNoiseSlotB(next)273 store.stampLocalMutation()274 true275 } else {276 false277 }278 } catch (_: Exception) {279 false280 }281 return if (healed) {282 VaultCrypto.zero(rawKey)283 adoptSlotA(newKey, payload) 284 } else {285 VaultCrypto.zero(newKey)286 adoptSlotA(rawKey, payload) 287 }288 }289290 291 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {292 return try {293 derivedKey = key294 decoyMode = false295 entryList = payload.entries.toMutableList()296 duressConfigured = payload.duressConfigured297 recoveryWordHash = payload.recoveryWordHash298 recoveryWordsSet = payload.recoveryWordsSet299 _settings.value = store.loadSettings()300 ensureRootKey(payload)301 autoPurgeExpiredTrash()302 publish()303 _isUnlocked.value = true304 UnlockResult.Success305 } catch (e: Exception) {306 UnlockResult.Error(e.message ?: "unlock_failed")307 }308 }309310 311312 313314315316317318 class SlotAUnlock internal constructor(319 internal val key: ByteArray,320 internal val payload: VaultPayload,321 )322323 324325326327328329 fun tryUnlockSlotA(password: String): SlotAUnlock? {330 val slotA = store.loadSlotA() ?: return null331 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))332 return try {333 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))334 SlotAUnlock(key, payload)335 } catch (_: Exception) {336 VaultCrypto.zero(key)337 null338 }339 }340341 342 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {343 adoptSlotA(unlock.key, unlock.payload)344 }345346 347 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)348349 350351 352353354355356 class DuressUnlock internal constructor(357 internal val key: ByteArray,358 internal val payload: VaultPayload,359 )360361 362363364365366367368 fun tryUnlockDuress(password: String): DuressUnlock? {369 val slot = store.loadDecoySlot() ?: return null370 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))371 return try {372 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))373 DuressUnlock(key, payload)374 } catch (_: Exception) {375 VaultCrypto.zero(key)376 null377 }378 }379380 381 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {382 return try {383 derivedKey = unlock.key384 decoyMode = true385 entryList = unlock.payload.entries.toMutableList()386 duressConfigured = false387 recoveryWordHash = unlock.payload.recoveryWordHash388 recoveryWordsSet = unlock.payload.recoveryWordsSet389 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)390 else VaultCrypto.generateRootKey()391 _settings.value = store.loadSettings()392 publish()393 _isUnlocked.value = true394 UnlockResult.Success395 } catch (e: Exception) {396 UnlockResult.Error(e.message ?: "decoy_unlock_failed")397 }398 }399400 401 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)402403 404405406407408409 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {410 if (derivedKey == null || decoyMode) return false411 412 413 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }414 return try {415 val salt = VaultCrypto.generateSalt()416 val key = VaultCrypto.deriveKey(duressPassphrase, salt)417 418 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))419 val plaintext = VaultCodec.encode(decoyPayload)420 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))421 VaultCrypto.zero(key)422 VaultCrypto.zero(plaintext)423 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))424 if (ok) {425 duressConfigured = true426 runCatching { persistSlotA(bumpVersion = false) }427 }428 ok429 } catch (_: Exception) {430 false431 }432 }433434 435 suspend fun clearDuress(): Boolean = mutex.withLock {436 store.clearDecoySlot()437 if (derivedKey != null && !decoyMode) {438 duressConfigured = false439 runCatching { persistSlotA(bumpVersion = false) }440 }441 true442 }443444 445 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {446 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")447 return try {448 val payload = VaultCodec.decode(449 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),450 )451 derivedKey = key.copyOf()452 decoyMode = false453 entryList = payload.entries.toMutableList()454 duressConfigured = payload.duressConfigured455 recoveryWordHash = payload.recoveryWordHash456 recoveryWordsSet = payload.recoveryWordsSet457 _settings.value = store.loadSettings()458 ensureRootKey(payload)459 autoPurgeExpiredTrash()460 publish()461 _isUnlocked.value = true462 UnlockResult.Success463 } catch (_: Exception) {464 UnlockResult.WrongPassword465 }466 }467468 fun lock() {469 derivedKey?.let(VaultCrypto::zero)470 rootKey?.let(VaultCrypto::zero)471 derivedKey = null472 rootKey = null473 entryList = mutableListOf()474 duressConfigured = false475 recoveryWordHash = null476 recoveryWordsSet = false477 decoyMode = false478 _entries.value = emptyList()479 _isUnlocked.value = false480 }481482 @Volatile private var suppressNextBackgroundLock = false483484 485486487488489490491492493 fun expectTransientBackground() {494 suppressNextBackgroundLock = true495 }496497 498 fun consumeTransientBackgroundSuppression(): Boolean {499 val suppressed = suppressNextBackgroundLock500 suppressNextBackgroundLock = false501 return suppressed502 }503504 505506 507508509510511 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()512513 514 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }515516 517518519520521 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {522 val key = derivedKey ?: return false523 if (decoyMode) return false524 val priorSlot = store.loadSlotA() ?: return false525 val priorRootKey = rootKey ?: return false526 val priorRecoveryHash = recoveryWordHash527 val priorRecoverySet = recoveryWordsSet528 val nextRootKey = generateRecoverableRootKey()529 val nextWords = rootKeyToWords(nextRootKey)530531 try {532 takeVaultSnapshot("local_save")533 rootKey = nextRootKey534 recoveryWordHash = recoveryWordsHash(nextWords)535 recoveryWordsSet = false536537 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)538 ?: throw IllegalStateException("recovery_escrow_failed")539 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)540 ?: throw IllegalStateException("root_escrow_failed")541 val plaintext = VaultCodec.encode(currentPayload())542 val blob = try {543 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))544 } finally {545 VaultCrypto.zero(plaintext)546 }547 val nextSlot = priorSlot.copy(548 blob = blob,549 recoveryEscrow = nextRecoveryEscrow,550 rootKeyEscrow = nextRootKeyEscrow,551 )552 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")553 try {554 writeMatchingNoiseSlotB(nextSlot)555 store.stampLocalMutation()556 } catch (e: Exception) {557 store.writeSlotA(priorSlot)558 throw e559 }560 VaultCrypto.zero(priorRootKey)561 publish()562 true563 } catch (_: Exception) {564 rootKey = priorRootKey565 recoveryWordHash = priorRecoveryHash566 recoveryWordsSet = priorRecoverySet567 VaultCrypto.zero(nextRootKey)568 store.writeSlotA(priorSlot)569 false570 }571 }572573 574575576577578 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()579580 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {581 val slotA = store.loadSlotA() ?: return false582 return try {583 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))584 val payload = VaultCodec.decode(585 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),586 )587 val newSalt = VaultCrypto.generateSalt()588 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)589 590 val plaintext = VaultCodec.encode(payload)591 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))592 593 594 595 596 597 598 599 val next = StoredVault(600 version = slotA.version,601 salt = b64(newSalt),602 blob = blob,603 recoveryEscrow = slotA.recoveryEscrow,604 rootKeyEscrow = slotA.rootKeyEscrow,605 )606 if (!store.writeSlotA(next)) return false607 writeMatchingNoiseSlotB(next)608 store.stampLocalMutation()609 VaultCrypto.zero(oldKey)610 derivedKey?.let(VaultCrypto::zero)611 derivedKey = newKey612 true613 } catch (_: Exception) {614 false615 }616 }617618 619620621622 suspend fun destroyVault() = mutex.withLock {623 lock()624 store.clearVault()625 store.clearDecoySlot()626 }627628 629630 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {631 requireUnlocked()632 val stamp = VaultCrypto.monotonicEntryTimestamp()633 val now = System.currentTimeMillis()634 val entry = withDerivedLast4(draft.copy(635 id = draft.id.ifBlank { UUID.randomUUID().toString() },636 createdAt = if (draft.createdAt > 0) draft.createdAt else now,637 modifiedAt = stamp,638 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,639 ))640 val rollback = entryList.toMutableList()641 entryList.add(entry)642 persistOrRollback(rollback)643 entry644 }645646 647648649650651652653654655 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {656 requireUnlocked()657 if (drafts.isEmpty()) return 0658 val rollback = entryList.toMutableList()659 val now = System.currentTimeMillis()660 for (draft in drafts) {661 val stamp = VaultCrypto.monotonicEntryTimestamp()662 entryList.add(663 withDerivedLast4(664 draft.copy(665 id = draft.id.ifBlank { UUID.randomUUID().toString() },666 createdAt = if (draft.createdAt > 0) draft.createdAt else now,667 modifiedAt = stamp,668 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,669 ),670 ),671 )672 }673 persistOrRollback(rollback)674 return drafts.size675 }676677 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {678 requireUnlocked()679 val idx = entryList.indexOfFirst { it.id == patch.id }680 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")681 val current = entryList[idx]682 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)683 684 val passwordChanged = patch.password != null && patch.password != current.password685 686 687 688 689 690 691 val priorPassword = current.password692 val shouldCapture = passwordChanged &&693 !priorPassword.isNullOrEmpty() &&694 current.passwordHistory?.firstOrNull()?.password != priorPassword695 val nextHistory = if (shouldCapture) {696 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +697 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)698 } else {699 current.passwordHistory700 }701 val updated = withDerivedLast4(patch.copy(702 createdAt = current.createdAt,703 modifiedAt = stamp,704 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,705 passwordHistory = nextHistory,706 ))707 val rollback = entryList.toMutableList()708 entryList[idx] = updated709 persistOrRollback(rollback)710 updated711 }712713 714 suspend fun deleteEntry(id: String) = mutex.withLock {715 requireUnlocked()716 val idx = entryList.indexOfFirst { it.id == id }717 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")718 val current = entryList[idx]719 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)720 val rollback = entryList.toMutableList()721 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)722 persistOrRollback(rollback)723 }724725 suspend fun restoreEntry(id: String) = mutex.withLock {726 requireUnlocked()727 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }728 if (idx == -1) throw IllegalStateException("deleted entry not found")729 val current = entryList[idx]730 val rollback = entryList.toMutableList()731 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))732 persistOrRollback(rollback)733 }734735 736 suspend fun purgeEntry(id: String) = mutex.withLock {737 requireUnlocked()738 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }739 if (idx == -1) throw IllegalStateException("deleted entry not found")740 val rollback = entryList.toMutableList()741 entryList.removeAt(idx)742 persistOrRollback(rollback)743 }744745 746 suspend fun purgeDeletedEntries(): Int = mutex.withLock {747 requireUnlocked()748 val rollback = entryList.toMutableList()749 val before = entryList.size750 entryList.removeAll { it.deletedAt != null }751 val removed = before - entryList.size752 if (removed > 0) persistOrRollback(rollback)753 removed754 }755756 757 suspend fun countDuplicateEntries(): Int = mutex.withLock {758 requireUnlocked()759 countDuplicateEntriesLocked()760 }761762 763764765766767 suspend fun deduplicateEntries(): Int = mutex.withLock {768 requireUnlocked()769 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")770 val active = entryList.filter { it.deletedAt == null }771 val keepIds = mutableSetOf<String>()772 active.groupBy { it.duplicateKey() }.values.forEach { group ->773 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id774 }775776 val rollback = entryList.toMutableList()777 var removed = 0778 entryList = entryList.map { entry ->779 if (entry.deletedAt == null && entry.id !in keepIds) {780 removed++781 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)782 entry.copy(deletedAt = stamp, modifiedAt = stamp)783 } else {784 entry785 }786 }.toMutableList()787 if (removed > 0) persistOrRollback(rollback)788 removed789 }790791 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }792 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }793 fun getDeletedEntries(): List<VaultEntry> =794 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }795796 797 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {798 val active = entryList.filter { it.deletedAt == null }799 if (tagFilter == null) return active800 val needle = tagFilter.trim().lowercase()801 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }802 }803804 805 fun getDistinctTags(): List<String> =806 entryList.asSequence()807 .filter { it.deletedAt == null }808 .flatMap { it.tags?.asSequence() ?: emptySequence() }809 .map { it.trim().lowercase() }810 .filter { it.isNotEmpty() }811 .distinct()812 .sorted()813 .toList()814815 816817818819820821 fun getDistinctFieldValues(field: String): List<String> {822 val select: (VaultEntry) -> String? = when (field) {823 "username" -> { e -> e.username }824 "email" -> { e -> e.email }825 else -> return emptyList()826 }827 return entryList.asSequence()828 .filter { it.deletedAt == null }829 .mapNotNull { select(it)?.trim() }830 .filter { it.isNotEmpty() }831 .groupingBy { it.lowercase() }832 .aggregate { _, acc: Pair<String, Int>?, value, _ ->833 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)834 }835 .values836 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })837 .map { it.first }838 }839840 fun getTombstones(): List<Tombstone> =841 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }842 fun getVaultVersion(): Long = store.version()843 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots844845 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {846 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false847 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false848 takeVaultSnapshot("restore")849 if (!store.writeSlotA(restoredA)) return false850 if (!store.writeSlotB(snapshot.vault_b)) return false851 store.stampLocalMutation()852 lock()853 true854 }855856 857858 859860861862863 suspend fun applyMergedRemoteEntries(864 remoteEntries: List<VaultEntry>,865 remoteTombstones: List<Tombstone>,866 ): Int = mutex.withLock {867 requireUnlocked()868 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }869 var applied = 0870 val byId = entryList.associateBy { it.id }.toMutableMap()871872 for (remote in remoteEntries) {873 val tomb = tombMap[remote.id]874 if (tomb != null && tomb >= remote.modifiedAt) continue875 val local = byId[remote.id]876 if (local == null) {877 entryList.add(remote); byId[remote.id] = remote; applied++878 } else if (remote.modifiedAt > local.modifiedAt) {879 val i = entryList.indexOfFirst { it.id == local.id }880 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }881 }882 }883884 for ((id, deletedAt) in tombMap) {885 val local = byId[id] ?: continue886 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue887 if (deletedAt < local.modifiedAt) continue888 val i = entryList.indexOfFirst { it.id == id }889 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }890 }891892 if (applied > 0) {893 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")894 publish()895 }896 applied897 }898899 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {900 _settings.value = settings901 store.writeSettings(settings)902 }903904 905906 907 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet908909 910911912913914 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {915 requireUnlocked()916 recoveryWordHash = recoveryWordsHash(words)917 recoveryWordsSet = true918 919 920 921 922 runCatching {923 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))924 }.getOrDefault(false)925 }926927 928 fun verifyRecoveryWords(words: List<String>): Boolean {929 val stored = recoveryWordHash ?: return false930 return stored == recoveryWordsHash(words)931 }932933 934935 936 fun hasRecoveryEscrow(): Boolean =937 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false938939 940941942943944945946947948949950951 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {952 val slotA = store.loadSlotA() ?: return false953 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false954955 956 957 slotA.recoveryEscrow?.let { escrow ->958 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let959 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))960 val keyBytes = recoveryKey.encoded961 try {962 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)963 val payload = VaultCodec.decode(plaintext)964 decoyMode = false965 entryList = payload.entries.toMutableList()966 duressConfigured = payload.duressConfigured967 recoveryWordHash = payload.recoveryWordHash968 recoveryWordsSet = payload.recoveryWordsSet969 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)970 else generateRecoverableRootKey()971 _settings.value = store.loadSettings()972 VaultCrypto.zero(plaintext)973 return true974 } catch (_: Exception) {975 976 } finally {977 VaultCrypto.zero(material)978 VaultCrypto.zero(keyBytes)979 }980 }981982 983 984 985 slotA.rootKeyEscrow?.let { escrow ->986 val rk = unsealRootKeyEscrow(words, escrow) ?: return false987 decoyMode = false988 entryList = mutableListOf()989 duressConfigured = false990 recoveryWordHash = recoveryWordsHash(words)991 recoveryWordsSet = true992 rootKey = rk993 _settings.value = store.loadSettings()994 return true995 }996 return false997 }998999 100010011002100310041005100610071008 suspend fun resetPasswordWithRecoveryWords(1009 words: List<String>,1010 newPassword: String,1011 ): Boolean = mutex.withLock {1012 if (rootKey == null) return false 1013 return try {1014 val newSalt = VaultCrypto.generateSalt()1015 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1016 val plaintext = VaultCodec.encode(currentPayload())1017 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1018 val prior = store.loadSlotA()1019 1020 1021 1022 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1023 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1024 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1025 val next = StoredVault(1026 version = prior?.version ?: 1,1027 salt = b64(newSalt),1028 blob = blob,1029 recoveryEscrow = freshRecovery,1030 rootKeyEscrow = freshRootKey,1031 )1032 if (!store.writeSlotA(next)) return false1033 writeMatchingNoiseSlotB(next)1034 store.stampLocalMutation()1035 derivedKey?.let(VaultCrypto::zero)1036 derivedKey = newKey1037 VaultCrypto.zero(plaintext)1038 publish()1039 _isUnlocked.value = true1040 true1041 } catch (_: Exception) {1042 false1043 }1044 }10451046 104710481049105010511052 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1053 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1054 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1055 val salt = VaultCrypto.generateSalt()1056 val recoveryKey = deriveRecoveryKey(material, salt)1057 val keyBytes = recoveryKey.encoded1058 val plaintext = VaultCodec.encode(currentPayload())1059 return try {1060 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1061 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1062 } finally {1063 VaultCrypto.zero(material)1064 VaultCrypto.zero(keyBytes)1065 VaultCrypto.zero(plaintext)1066 }1067 }10681069 10701071107210731074107510761077 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1078 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1079 val rk = rootKey ?: return null1080 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1081 val salt = VaultCrypto.generateSalt()1082 val recoveryKey = deriveRecoveryKey(material, salt)1083 val keyBytes = recoveryKey.encoded1084 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1085 return try {1086 1087 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1088 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1089 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1090 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1091 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1092 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1093 } finally {1094 VaultCrypto.zero(material)1095 VaultCrypto.zero(keyBytes)1096 VaultCrypto.zero(plaintext)1097 }1098 }10991100 1101 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1102 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1103 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1104 val keyBytes = recoveryKey.encoded1105 return try {1106 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1107 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1108 hexToRootKey(String(pt, Charsets.US_ASCII))1109 } catch (_: Exception) {1110 null1111 } finally {1112 VaultCrypto.zero(material)1113 VaultCrypto.zero(keyBytes)1114 }1115 }11161117 111811191120112111221123 private fun persistSlotAWithEscrows(1124 recoveryEscrow: RecoveryEscrow?,1125 rootKeyEscrow: RootKeyEscrow?,1126 ): Boolean {1127 val key = derivedKey ?: throw IllegalStateException("vault_locked")1128 if (decoyMode) return false1129 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1130 val plaintext = VaultCodec.encode(currentPayload())1131 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1132 val next = prior.copy(1133 blob = blob,1134 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1135 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1136 )1137 if (!store.writeSlotA(next)) return false1138 try {1139 writeMatchingNoiseSlotB(next)1140 } catch (e: Exception) {1141 store.writeSlotA(prior)1142 throw e1143 }1144 return true1145 }11461147 11481149 private fun requireUnlocked() {1150 if (derivedKey == null) throw IllegalStateException("vault_locked")1151 }11521153 private fun countDuplicateEntriesLocked(): Int {1154 val seen = mutableMapOf<String, VaultEntry>()1155 var duplicates = 01156 for (entry in entryList.filter { it.deletedAt == null }) {1157 val key = entry.duplicateKey()1158 val existing = seen[key]1159 if (existing == null) {1160 seen[key] = entry1161 } else {1162 duplicates++1163 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1164 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1165 if (entryStamp > existingStamp) seen[key] = entry1166 }1167 }1168 return duplicates1169 }11701171 private fun VaultEntry.duplicateKey(): String {1172 if (type == "note") return "note|${title.lowercase()}"1173 val passkeyData = passkey1174 if (passkeyData != null) {1175 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1176 }1177 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1178 }11791180 private fun takeVaultSnapshot(reason: String) {1181 runCatching {1182 val slotA = store.loadSlotA() ?: return1183 val vaultA = VaultCodec.encodeSlot(slotA)1184 val vaultB = store.loadSlotB() ?: slotA.blob1185 val history = store.loadVaultHistory()1186 val latest = history.snapshots.firstOrNull()1187 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1188 val entryCount = if (derivedKey != null && !decoyMode) {1189 bucketEntryCount(entryList.count { it.deletedAt == null })1190 } else {1191 null1192 }1193 val snapshot = VaultSnapshot(1194 id = UUID.randomUUID().toString(),1195 savedAt = System.currentTimeMillis(),1196 reason = reason,1197 vault_a = vaultA,1198 vault_b = vaultB,1199 entryCount = entryCount,1200 )1201 store.writeVaultHistory(1202 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1203 )1204 }1205 }12061207 private fun bucketEntryCount(n: Int): Int = when {1208 n <= 0 -> 01209 n <= 10 -> 101210 n <= 25 -> 251211 n <= 50 -> 501212 n <= 100 -> 1001213 n <= 250 -> 2501214 n <= 500 -> 5001215 else -> ((n + 499) / 500) * 5001216 }12171218 12191220122112221223 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1224 if (!entry.isCard()) return entry1225 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1226 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1227 }12281229 123012311232123312341235 private fun autoPurgeExpiredTrash() {1236 val days = _settings.value.trashRetentionDays ?: return1237 if (days <= 0) return1238 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1239 val before = entryList.size1240 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1241 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1242 }12431244 1245 private fun currentPayload(): VaultPayload =1246 VaultPayload(1247 entries = entryList.toList(),1248 duressConfigured = duressConfigured,1249 rootKeyB64 = rootKey?.let(::b64) ?: "",1250 recoveryWordHash = recoveryWordHash,1251 recoveryWordsSet = recoveryWordsSet,1252 )12531254 12551256125712581259 private fun ensureRootKey(payload: VaultPayload) {1260 if (payload.rootKeyB64.isNotBlank()) {1261 rootKey = unb64(payload.rootKeyB64)1262 return1263 }1264 rootKey = generateRecoverableRootKey()1265 runCatching { persistSlotA(bumpVersion = false) }1266 }12671268 private fun publish() {1269 _entries.value = entryList.filter { it.deletedAt == null }1270 }12711272 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1273 val plaintext = VaultCodec.encode(currentPayload())1274 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1275 return StoredVault(version = 1, salt = b64(salt), blob = blob)1276 }12771278 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1279 1280 1281 1282 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1283 val noise = ByteArray(approxBytes).also(rng::nextBytes)1284 store.writeSlotB(b64(noise))1285 }12861287 1288 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1289 val key = derivedKey ?: throw IllegalStateException("vault_locked")1290 1291 if (decoyMode) {1292 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1293 val plaintext = VaultCodec.encode(currentPayload())1294 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1295 return store.writeDecoySlot(prior.copy(blob = blob))1296 }1297 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1298 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1299 val plaintext = VaultCodec.encode(currentPayload())1300 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1301 val next = prior.copy(blob = blob)1302 if (!store.writeSlotA(next)) return false1303 1304 1305 try {1306 writeMatchingNoiseSlotB(next)1307 if (bumpVersion) store.stampLocalMutation()1308 } catch (e: Exception) {1309 store.writeSlotA(prior)1310 throw e1311 }1312 return true1313 }13141315 1316 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1317 try {1318 if (!persistSlotA(bumpVersion = true)) {1319 entryList = rollback1320 throw IllegalStateException("storage_write_failed")1321 }1322 } catch (e: Exception) {1323 entryList = rollback1324 throw e1325 }1326 publish()1327 }1328}