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 java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)171819private const val PASSWORD_HISTORY_MAX = 5020private const val VAULT_HISTORY_MAX = 52122sealed interface UnlockResult {23 data object Success : UnlockResult24 data object WrongPassword : UnlockResult25 data class Error(val reason: String) : UnlockResult26}2728sealed interface CreateVaultResult {29 data object Success : CreateVaultResult30 data class Error(val reason: String) : CreateVaultResult31}3233sealed interface AdoptVaultResult {34 data class Success(val vaultId: String) : AdoptVaultResult35 data class Error(val reason: String) : AdoptVaultResult36}373839404142434445464748495051@Singleton52class VaultRepository @Inject constructor(53 private val store: VaultStorage,54) {55 private val mutex = Mutex()56 private val rng = SecureRandom()5758 private var derivedKey: ByteArray? = null59 private var rootKey: ByteArray? = null60 private var entryList: MutableList<VaultEntry> = mutableListOf()61 private var duressConfigured: Boolean = false62 private var recoveryWordHash: String? = null63 private var recoveryWordsSet: Boolean = false64 65 private var decoyMode: Boolean = false6667 private val _isUnlocked = MutableStateFlow(false)68 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6970 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())71 72 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()7374 private val _settings = MutableStateFlow(VaultSettings())75 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7677 fun hasVault(): Boolean = store.hasVault()7879 8081 8283848586 fun codexVaultBlobBase64(): String? {87 val slotA = store.loadSlotA() ?: return null88 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""89 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))90 }9192 9394959697 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {98 return try {99 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)100 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)101 ?: return UnlockResult.Error("invalid_codex_blob")102 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")103 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")104 writeMatchingNoiseSlotB(slotA)105 store.stampLocalMutation()106 unlockLocked(password)107 } catch (e: Exception) {108 UnlockResult.Error(e.message ?: "restore_failed")109 }110 }111112 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {113 return try {114 val salt = VaultCrypto.generateSalt()115 val key = VaultCrypto.deriveKey(password, salt)116 entryList = mutableListOf()117 duressConfigured = false118 recoveryWordHash = null119 recoveryWordsSet = false120 decoyMode = false121 rootKey = generateRecoverableRootKey()122 val slotA = sealSlotA(key, salt)123 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")124 writeMatchingNoiseSlotB(slotA)125 store.stampLocalMutation()126 store.writeSettings(_settings.value)127 derivedKey = key128 publish()129 _isUnlocked.value = true130 CreateVaultResult.Success131 } catch (e: Exception) {132 CreateVaultResult.Error(e.message ?: "create_failed")133 }134 }135136 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {137 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")138 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {139 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")140 }141 var adoptedRootKey: ByteArray? = null142 var nextDerivedKey: ByteArray? = null143 var sessionRootKey: ByteArray? = null144 return try {145 adoptedRootKey = hexToRootKey(rootKeyHex)146 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {147 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")148 }149 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)150 val salt = VaultCrypto.generateSalt()151 nextDerivedKey = VaultCrypto.deriveKey(password, salt)152 sessionRootKey = adoptedRootKey.copyOf()153 val payload = VaultPayload(154 entries = emptyList(),155 duressConfigured = false,156 rootKeyB64 = b64(sessionRootKey),157 recoveryWordHash = null,158 recoveryWordsSet = false,159 )160 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))161 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)162 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")163 writeMatchingNoiseSlotB(slotA)164 store.stampLocalMutation()165 store.writeSettings(_settings.value)166167 val previousDerivedKey = derivedKey168 val previousRootKey = rootKey169 derivedKey = nextDerivedKey170 rootKey = sessionRootKey171 nextDerivedKey = null172 sessionRootKey = null173 entryList = mutableListOf()174 duressConfigured = false175 recoveryWordHash = null176 recoveryWordsSet = false177 decoyMode = false178 publish()179 _isUnlocked.value = true180 previousDerivedKey?.let(VaultCrypto::zero)181 previousRootKey?.let(VaultCrypto::zero)182 AdoptVaultResult.Success(vaultId)183 } catch (e: Exception) {184 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")185 } finally {186 adoptedRootKey?.let(VaultCrypto::zero)187 nextDerivedKey?.let(VaultCrypto::zero)188 sessionRootKey?.let(VaultCrypto::zero)189 }190 }191192 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }193194 195 private fun unlockLocked(password: String): UnlockResult {196 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")197 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))198 val payload = try {199 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))200 } catch (_: Exception) {201 VaultCrypto.zero(key)202 203 204 205 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {206 val legacy = tryLegacyRawUnlock(password, slotA)207 if (legacy != null) return legacy208 }209 return UnlockResult.WrongPassword210 }211 return adoptSlotA(key, payload)212 }213214 215216217218219220 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {221 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))222 val payload = try {223 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))224 } catch (_: Exception) {225 VaultCrypto.zero(rawKey)226 return null227 }228 229 230 val newSalt = VaultCrypto.generateSalt()231 val newKey = VaultCrypto.deriveKey(password, newSalt)232 val healed = try {233 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))234 val next = StoredVault(235 version = slotA.version,236 salt = b64(newSalt),237 blob = blob,238 recoveryEscrow = slotA.recoveryEscrow,239 rootKeyEscrow = slotA.rootKeyEscrow,240 )241 if (store.writeSlotA(next)) {242 writeMatchingNoiseSlotB(next)243 store.stampLocalMutation()244 true245 } else {246 false247 }248 } catch (_: Exception) {249 false250 }251 return if (healed) {252 VaultCrypto.zero(rawKey)253 adoptSlotA(newKey, payload) 254 } else {255 VaultCrypto.zero(newKey)256 adoptSlotA(rawKey, payload) 257 }258 }259260 261 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {262 return try {263 derivedKey = key264 decoyMode = false265 entryList = payload.entries.toMutableList()266 duressConfigured = payload.duressConfigured267 recoveryWordHash = payload.recoveryWordHash268 recoveryWordsSet = payload.recoveryWordsSet269 _settings.value = store.loadSettings()270 ensureRootKey(payload)271 autoPurgeExpiredTrash()272 publish()273 _isUnlocked.value = true274 UnlockResult.Success275 } catch (e: Exception) {276 UnlockResult.Error(e.message ?: "unlock_failed")277 }278 }279280 281282 283284285286287288 class SlotAUnlock internal constructor(289 internal val key: ByteArray,290 internal val payload: VaultPayload,291 )292293 294295296297298299 fun tryUnlockSlotA(password: String): SlotAUnlock? {300 val slotA = store.loadSlotA() ?: return null301 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))302 return try {303 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))304 SlotAUnlock(key, payload)305 } catch (_: Exception) {306 VaultCrypto.zero(key)307 null308 }309 }310311 312 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {313 adoptSlotA(unlock.key, unlock.payload)314 }315316 317 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)318319 320321 322323324325326 class DuressUnlock internal constructor(327 internal val key: ByteArray,328 internal val payload: VaultPayload,329 )330331 332333334335336337338 fun tryUnlockDuress(password: String): DuressUnlock? {339 val slot = store.loadDecoySlot() ?: return null340 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))341 return try {342 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))343 DuressUnlock(key, payload)344 } catch (_: Exception) {345 VaultCrypto.zero(key)346 null347 }348 }349350 351 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {352 return try {353 derivedKey = unlock.key354 decoyMode = true355 entryList = unlock.payload.entries.toMutableList()356 duressConfigured = false357 recoveryWordHash = unlock.payload.recoveryWordHash358 recoveryWordsSet = unlock.payload.recoveryWordsSet359 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)360 else VaultCrypto.generateRootKey()361 _settings.value = store.loadSettings()362 publish()363 _isUnlocked.value = true364 UnlockResult.Success365 } catch (e: Exception) {366 UnlockResult.Error(e.message ?: "decoy_unlock_failed")367 }368 }369370 371 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)372373 374375376377378379 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {380 if (derivedKey == null || decoyMode) return false381 382 383 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }384 return try {385 val salt = VaultCrypto.generateSalt()386 val key = VaultCrypto.deriveKey(duressPassphrase, salt)387 388 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))389 val plaintext = VaultCodec.encode(decoyPayload)390 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))391 VaultCrypto.zero(key)392 VaultCrypto.zero(plaintext)393 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))394 if (ok) {395 duressConfigured = true396 runCatching { persistSlotA(bumpVersion = false) }397 }398 ok399 } catch (_: Exception) {400 false401 }402 }403404 405 suspend fun clearDuress(): Boolean = mutex.withLock {406 store.clearDecoySlot()407 if (derivedKey != null && !decoyMode) {408 duressConfigured = false409 runCatching { persistSlotA(bumpVersion = false) }410 }411 true412 }413414 415 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {416 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")417 return try {418 val payload = VaultCodec.decode(419 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),420 )421 derivedKey = key.copyOf()422 decoyMode = false423 entryList = payload.entries.toMutableList()424 duressConfigured = payload.duressConfigured425 recoveryWordHash = payload.recoveryWordHash426 recoveryWordsSet = payload.recoveryWordsSet427 _settings.value = store.loadSettings()428 ensureRootKey(payload)429 autoPurgeExpiredTrash()430 publish()431 _isUnlocked.value = true432 UnlockResult.Success433 } catch (_: Exception) {434 UnlockResult.WrongPassword435 }436 }437438 fun lock() {439 derivedKey?.let(VaultCrypto::zero)440 rootKey?.let(VaultCrypto::zero)441 derivedKey = null442 rootKey = null443 entryList = mutableListOf()444 duressConfigured = false445 recoveryWordHash = null446 recoveryWordsSet = false447 decoyMode = false448 _entries.value = emptyList()449 _isUnlocked.value = false450 }451452 @Volatile private var suppressNextBackgroundLock = false453454 455456457458459460461462463 fun expectTransientBackground() {464 suppressNextBackgroundLock = true465 }466467 468 fun consumeTransientBackgroundSuppression(): Boolean {469 val suppressed = suppressNextBackgroundLock470 suppressNextBackgroundLock = false471 return suppressed472 }473474 475476 477478479480481 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()482483 484 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }485486 487488489490491 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {492 val key = derivedKey ?: return false493 if (decoyMode) return false494 val priorSlot = store.loadSlotA() ?: return false495 val priorRootKey = rootKey ?: return false496 val priorRecoveryHash = recoveryWordHash497 val priorRecoverySet = recoveryWordsSet498 val nextRootKey = generateRecoverableRootKey()499 val nextWords = rootKeyToWords(nextRootKey)500501 try {502 takeVaultSnapshot("local_save")503 rootKey = nextRootKey504 recoveryWordHash = recoveryWordsHash(nextWords)505 recoveryWordsSet = false506507 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)508 ?: throw IllegalStateException("recovery_escrow_failed")509 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)510 ?: throw IllegalStateException("root_escrow_failed")511 val plaintext = VaultCodec.encode(currentPayload())512 val blob = try {513 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))514 } finally {515 VaultCrypto.zero(plaintext)516 }517 val nextSlot = priorSlot.copy(518 blob = blob,519 recoveryEscrow = nextRecoveryEscrow,520 rootKeyEscrow = nextRootKeyEscrow,521 )522 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")523 try {524 writeMatchingNoiseSlotB(nextSlot)525 store.stampLocalMutation()526 } catch (e: Exception) {527 store.writeSlotA(priorSlot)528 throw e529 }530 VaultCrypto.zero(priorRootKey)531 publish()532 true533 } catch (_: Exception) {534 rootKey = priorRootKey535 recoveryWordHash = priorRecoveryHash536 recoveryWordsSet = priorRecoverySet537 VaultCrypto.zero(nextRootKey)538 store.writeSlotA(priorSlot)539 false540 }541 }542543 544545546547548 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()549550 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {551 val slotA = store.loadSlotA() ?: return false552 return try {553 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))554 val payload = VaultCodec.decode(555 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),556 )557 val newSalt = VaultCrypto.generateSalt()558 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)559 560 val plaintext = VaultCodec.encode(payload)561 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))562 563 564 565 566 567 568 569 val next = StoredVault(570 version = slotA.version,571 salt = b64(newSalt),572 blob = blob,573 recoveryEscrow = slotA.recoveryEscrow,574 rootKeyEscrow = slotA.rootKeyEscrow,575 )576 if (!store.writeSlotA(next)) return false577 writeMatchingNoiseSlotB(next)578 store.stampLocalMutation()579 VaultCrypto.zero(oldKey)580 derivedKey?.let(VaultCrypto::zero)581 derivedKey = newKey582 true583 } catch (_: Exception) {584 false585 }586 }587588 589590591592 suspend fun destroyVault() = mutex.withLock {593 lock()594 store.clearVault()595 store.clearDecoySlot()596 }597598 599600 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {601 requireUnlocked()602 val stamp = VaultCrypto.monotonicEntryTimestamp()603 val now = System.currentTimeMillis()604 val entry = withDerivedLast4(draft.copy(605 id = draft.id.ifBlank { UUID.randomUUID().toString() },606 createdAt = if (draft.createdAt > 0) draft.createdAt else now,607 modifiedAt = stamp,608 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,609 ))610 val rollback = entryList.toMutableList()611 entryList.add(entry)612 persistOrRollback(rollback)613 entry614 }615616 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {617 requireUnlocked()618 val idx = entryList.indexOfFirst { it.id == patch.id }619 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")620 val current = entryList[idx]621 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)622 623 val passwordChanged = patch.password != null && patch.password != current.password624 625 626 627 628 629 630 val priorPassword = current.password631 val shouldCapture = passwordChanged &&632 !priorPassword.isNullOrEmpty() &&633 current.passwordHistory?.firstOrNull()?.password != priorPassword634 val nextHistory = if (shouldCapture) {635 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +636 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)637 } else {638 current.passwordHistory639 }640 val updated = withDerivedLast4(patch.copy(641 createdAt = current.createdAt,642 modifiedAt = stamp,643 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,644 passwordHistory = nextHistory,645 ))646 val rollback = entryList.toMutableList()647 entryList[idx] = updated648 persistOrRollback(rollback)649 updated650 }651652 653 suspend fun deleteEntry(id: String) = mutex.withLock {654 requireUnlocked()655 val idx = entryList.indexOfFirst { it.id == id }656 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")657 val current = entryList[idx]658 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)659 val rollback = entryList.toMutableList()660 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)661 persistOrRollback(rollback)662 }663664 suspend fun restoreEntry(id: String) = mutex.withLock {665 requireUnlocked()666 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }667 if (idx == -1) throw IllegalStateException("deleted entry not found")668 val current = entryList[idx]669 val rollback = entryList.toMutableList()670 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))671 persistOrRollback(rollback)672 }673674 675 suspend fun purgeEntry(id: String) = mutex.withLock {676 requireUnlocked()677 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }678 if (idx == -1) throw IllegalStateException("deleted entry not found")679 val rollback = entryList.toMutableList()680 entryList.removeAt(idx)681 persistOrRollback(rollback)682 }683684 685 suspend fun purgeDeletedEntries(): Int = mutex.withLock {686 requireUnlocked()687 val rollback = entryList.toMutableList()688 val before = entryList.size689 entryList.removeAll { it.deletedAt != null }690 val removed = before - entryList.size691 if (removed > 0) persistOrRollback(rollback)692 removed693 }694695 696 suspend fun countDuplicateEntries(): Int = mutex.withLock {697 requireUnlocked()698 countDuplicateEntriesLocked()699 }700701 702703704705706 suspend fun deduplicateEntries(): Int = mutex.withLock {707 requireUnlocked()708 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")709 val active = entryList.filter { it.deletedAt == null }710 val keepIds = mutableSetOf<String>()711 active.groupBy { it.duplicateKey() }.values.forEach { group ->712 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id713 }714715 val rollback = entryList.toMutableList()716 var removed = 0717 entryList = entryList.map { entry ->718 if (entry.deletedAt == null && entry.id !in keepIds) {719 removed++720 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)721 entry.copy(deletedAt = stamp, modifiedAt = stamp)722 } else {723 entry724 }725 }.toMutableList()726 if (removed > 0) persistOrRollback(rollback)727 removed728 }729730 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }731 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }732 fun getDeletedEntries(): List<VaultEntry> =733 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }734735 736 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {737 val active = entryList.filter { it.deletedAt == null }738 if (tagFilter == null) return active739 val needle = tagFilter.trim().lowercase()740 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }741 }742743 744 fun getDistinctTags(): List<String> =745 entryList.asSequence()746 .filter { it.deletedAt == null }747 .flatMap { it.tags?.asSequence() ?: emptySequence() }748 .map { it.trim().lowercase() }749 .filter { it.isNotEmpty() }750 .distinct()751 .sorted()752 .toList()753754 755756757758759760 fun getDistinctFieldValues(field: String): List<String> {761 val select: (VaultEntry) -> String? = when (field) {762 "username" -> { e -> e.username }763 "email" -> { e -> e.email }764 else -> return emptyList()765 }766 return entryList.asSequence()767 .filter { it.deletedAt == null }768 .mapNotNull { select(it)?.trim() }769 .filter { it.isNotEmpty() }770 .groupingBy { it.lowercase() }771 .aggregate { _, acc: Pair<String, Int>?, value, _ ->772 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)773 }774 .values775 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })776 .map { it.first }777 }778779 fun getTombstones(): List<Tombstone> =780 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }781 fun getVaultVersion(): Long = store.version()782 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots783784 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {785 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false786 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false787 takeVaultSnapshot("restore")788 if (!store.writeSlotA(restoredA)) return false789 if (!store.writeSlotB(snapshot.vault_b)) return false790 store.stampLocalMutation()791 lock()792 true793 }794795 796797 798799800801802 suspend fun applyMergedRemoteEntries(803 remoteEntries: List<VaultEntry>,804 remoteTombstones: List<Tombstone>,805 ): Int = mutex.withLock {806 requireUnlocked()807 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }808 var applied = 0809 val byId = entryList.associateBy { it.id }.toMutableMap()810811 for (remote in remoteEntries) {812 val tomb = tombMap[remote.id]813 if (tomb != null && tomb >= remote.modifiedAt) continue814 val local = byId[remote.id]815 if (local == null) {816 entryList.add(remote); byId[remote.id] = remote; applied++817 } else if (remote.modifiedAt > local.modifiedAt) {818 val i = entryList.indexOfFirst { it.id == local.id }819 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }820 }821 }822823 for ((id, deletedAt) in tombMap) {824 val local = byId[id] ?: continue825 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue826 if (deletedAt < local.modifiedAt) continue827 val i = entryList.indexOfFirst { it.id == id }828 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }829 }830831 if (applied > 0) {832 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")833 publish()834 }835 applied836 }837838 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {839 _settings.value = settings840 store.writeSettings(settings)841 }842843 844845 846 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet847848 849850851852853 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {854 requireUnlocked()855 recoveryWordHash = recoveryWordsHash(words)856 recoveryWordsSet = true857 858 859 860 861 runCatching {862 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))863 }.getOrDefault(false)864 }865866 867 fun verifyRecoveryWords(words: List<String>): Boolean {868 val stored = recoveryWordHash ?: return false869 return stored == recoveryWordsHash(words)870 }871872 873874 875 fun hasRecoveryEscrow(): Boolean =876 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false877878 879880881882883884885886887888889890 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {891 val slotA = store.loadSlotA() ?: return false892 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false893894 895 896 slotA.recoveryEscrow?.let { escrow ->897 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let898 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))899 val keyBytes = recoveryKey.encoded900 try {901 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)902 val payload = VaultCodec.decode(plaintext)903 decoyMode = false904 entryList = payload.entries.toMutableList()905 duressConfigured = payload.duressConfigured906 recoveryWordHash = payload.recoveryWordHash907 recoveryWordsSet = payload.recoveryWordsSet908 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)909 else generateRecoverableRootKey()910 _settings.value = store.loadSettings()911 VaultCrypto.zero(plaintext)912 return true913 } catch (_: Exception) {914 915 } finally {916 VaultCrypto.zero(material)917 VaultCrypto.zero(keyBytes)918 }919 }920921 922 923 924 slotA.rootKeyEscrow?.let { escrow ->925 val rk = unsealRootKeyEscrow(words, escrow) ?: return false926 decoyMode = false927 entryList = mutableListOf()928 duressConfigured = false929 recoveryWordHash = recoveryWordsHash(words)930 recoveryWordsSet = true931 rootKey = rk932 _settings.value = store.loadSettings()933 return true934 }935 return false936 }937938 939940941942943944945946947 suspend fun resetPasswordWithRecoveryWords(948 words: List<String>,949 newPassword: String,950 ): Boolean = mutex.withLock {951 if (rootKey == null) return false 952 return try {953 val newSalt = VaultCrypto.generateSalt()954 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)955 val plaintext = VaultCodec.encode(currentPayload())956 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))957 val prior = store.loadSlotA()958 959 960 961 val valid = words.size == 12 && findInvalidWords(words).isEmpty()962 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow963 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow964 val next = StoredVault(965 version = prior?.version ?: 1,966 salt = b64(newSalt),967 blob = blob,968 recoveryEscrow = freshRecovery,969 rootKeyEscrow = freshRootKey,970 )971 if (!store.writeSlotA(next)) return false972 writeMatchingNoiseSlotB(next)973 store.stampLocalMutation()974 derivedKey?.let(VaultCrypto::zero)975 derivedKey = newKey976 VaultCrypto.zero(plaintext)977 publish()978 _isUnlocked.value = true979 true980 } catch (_: Exception) {981 false982 }983 }984985 986987988989990991 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {992 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null993 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null994 val salt = VaultCrypto.generateSalt()995 val recoveryKey = deriveRecoveryKey(material, salt)996 val keyBytes = recoveryKey.encoded997 val plaintext = VaultCodec.encode(currentPayload())998 return try {999 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1000 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1001 } finally {1002 VaultCrypto.zero(material)1003 VaultCrypto.zero(keyBytes)1004 VaultCrypto.zero(plaintext)1005 }1006 }10071008 10091010101110121013101410151016 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1017 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1018 val rk = rootKey ?: return null1019 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1020 val salt = VaultCrypto.generateSalt()1021 val recoveryKey = deriveRecoveryKey(material, salt)1022 val keyBytes = recoveryKey.encoded1023 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1024 return try {1025 1026 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1027 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1028 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1029 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1030 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1031 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1032 } finally {1033 VaultCrypto.zero(material)1034 VaultCrypto.zero(keyBytes)1035 VaultCrypto.zero(plaintext)1036 }1037 }10381039 1040 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1041 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1042 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1043 val keyBytes = recoveryKey.encoded1044 return try {1045 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1046 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1047 hexToRootKey(String(pt, Charsets.US_ASCII))1048 } catch (_: Exception) {1049 null1050 } finally {1051 VaultCrypto.zero(material)1052 VaultCrypto.zero(keyBytes)1053 }1054 }10551056 105710581059106010611062 private fun persistSlotAWithEscrows(1063 recoveryEscrow: RecoveryEscrow?,1064 rootKeyEscrow: RootKeyEscrow?,1065 ): Boolean {1066 val key = derivedKey ?: throw IllegalStateException("vault_locked")1067 if (decoyMode) return false1068 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1069 val plaintext = VaultCodec.encode(currentPayload())1070 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1071 val next = prior.copy(1072 blob = blob,1073 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1074 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1075 )1076 if (!store.writeSlotA(next)) return false1077 try {1078 writeMatchingNoiseSlotB(next)1079 } catch (e: Exception) {1080 store.writeSlotA(prior)1081 throw e1082 }1083 return true1084 }10851086 10871088 private fun requireUnlocked() {1089 if (derivedKey == null) throw IllegalStateException("vault_locked")1090 }10911092 private fun countDuplicateEntriesLocked(): Int {1093 val seen = mutableMapOf<String, VaultEntry>()1094 var duplicates = 01095 for (entry in entryList.filter { it.deletedAt == null }) {1096 val key = entry.duplicateKey()1097 val existing = seen[key]1098 if (existing == null) {1099 seen[key] = entry1100 } else {1101 duplicates++1102 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1103 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1104 if (entryStamp > existingStamp) seen[key] = entry1105 }1106 }1107 return duplicates1108 }11091110 private fun VaultEntry.duplicateKey(): String {1111 if (type == "note") return "note|${title.lowercase()}"1112 val passkeyData = passkey1113 if (passkeyData != null) {1114 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1115 }1116 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1117 }11181119 private fun takeVaultSnapshot(reason: String) {1120 runCatching {1121 val slotA = store.loadSlotA() ?: return1122 val vaultA = VaultCodec.encodeSlot(slotA)1123 val vaultB = store.loadSlotB() ?: slotA.blob1124 val history = store.loadVaultHistory()1125 val latest = history.snapshots.firstOrNull()1126 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1127 val entryCount = if (derivedKey != null && !decoyMode) {1128 bucketEntryCount(entryList.count { it.deletedAt == null })1129 } else {1130 null1131 }1132 val snapshot = VaultSnapshot(1133 id = UUID.randomUUID().toString(),1134 savedAt = System.currentTimeMillis(),1135 reason = reason,1136 vault_a = vaultA,1137 vault_b = vaultB,1138 entryCount = entryCount,1139 )1140 store.writeVaultHistory(1141 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1142 )1143 }1144 }11451146 private fun bucketEntryCount(n: Int): Int = when {1147 n <= 0 -> 01148 n <= 10 -> 101149 n <= 25 -> 251150 n <= 50 -> 501151 n <= 100 -> 1001152 n <= 250 -> 2501153 n <= 500 -> 5001154 else -> ((n + 499) / 500) * 5001155 }11561157 11581159116011611162 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1163 if (!entry.isCard()) return entry1164 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1165 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1166 }11671168 116911701171117211731174 private fun autoPurgeExpiredTrash() {1175 val days = _settings.value.trashRetentionDays ?: return1176 if (days <= 0) return1177 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1178 val before = entryList.size1179 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1180 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1181 }11821183 1184 private fun currentPayload(): VaultPayload =1185 VaultPayload(1186 entries = entryList.toList(),1187 duressConfigured = duressConfigured,1188 rootKeyB64 = rootKey?.let(::b64) ?: "",1189 recoveryWordHash = recoveryWordHash,1190 recoveryWordsSet = recoveryWordsSet,1191 )11921193 11941195119611971198 private fun ensureRootKey(payload: VaultPayload) {1199 if (payload.rootKeyB64.isNotBlank()) {1200 rootKey = unb64(payload.rootKeyB64)1201 return1202 }1203 rootKey = generateRecoverableRootKey()1204 runCatching { persistSlotA(bumpVersion = false) }1205 }12061207 private fun publish() {1208 _entries.value = entryList.filter { it.deletedAt == null }1209 }12101211 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1212 val plaintext = VaultCodec.encode(currentPayload())1213 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1214 return StoredVault(version = 1, salt = b64(salt), blob = blob)1215 }12161217 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1218 1219 1220 1221 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1222 val noise = ByteArray(approxBytes).also(rng::nextBytes)1223 store.writeSlotB(b64(noise))1224 }12251226 1227 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1228 val key = derivedKey ?: throw IllegalStateException("vault_locked")1229 1230 if (decoyMode) {1231 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1232 val plaintext = VaultCodec.encode(currentPayload())1233 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1234 return store.writeDecoySlot(prior.copy(blob = blob))1235 }1236 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1237 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1238 val plaintext = VaultCodec.encode(currentPayload())1239 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1240 val next = prior.copy(blob = blob)1241 if (!store.writeSlotA(next)) return false1242 1243 1244 try {1245 writeMatchingNoiseSlotB(next)1246 if (bumpVersion) store.stampLocalMutation()1247 } catch (e: Exception) {1248 store.writeSlotA(prior)1249 throw e1250 }1251 return true1252 }12531254 1255 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1256 try {1257 if (!persistSlotA(bumpVersion = true)) {1258 entryList = rollback1259 throw IllegalStateException("storage_write_failed")1260 }1261 } catch (e: Exception) {1262 entryList = rollback1263 throw e1264 }1265 publish()1266 }1267}