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 101 102 103 104 105 106 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapperJson)?.groupValues?.get(1)107 ?: return UnlockResult.Error("invalid_codex_blob")108 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")109 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")110 writeMatchingNoiseSlotB(slotA)111 store.stampLocalMutation()112 unlockLocked(password)113 } catch (e: Exception) {114 UnlockResult.Error(e.message ?: "restore_failed")115 }116 }117118 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {119 return try {120 val salt = VaultCrypto.generateSalt()121 val key = VaultCrypto.deriveKey(password, salt)122 entryList = mutableListOf()123 duressConfigured = false124 recoveryWordHash = null125 recoveryWordsSet = false126 decoyMode = false127 rootKey = generateRecoverableRootKey()128 val slotA = sealSlotA(key, salt)129 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")130 writeMatchingNoiseSlotB(slotA)131 store.stampLocalMutation()132 store.writeSettings(_settings.value)133 derivedKey = key134 publish()135 _isUnlocked.value = true136 CreateVaultResult.Success137 } catch (e: Exception) {138 CreateVaultResult.Error(e.message ?: "create_failed")139 }140 }141142 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {143 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")144 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {145 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")146 }147 var adoptedRootKey: ByteArray? = null148 var nextDerivedKey: ByteArray? = null149 var sessionRootKey: ByteArray? = null150 return try {151 adoptedRootKey = hexToRootKey(rootKeyHex)152 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {153 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")154 }155 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)156 val salt = VaultCrypto.generateSalt()157 nextDerivedKey = VaultCrypto.deriveKey(password, salt)158 sessionRootKey = adoptedRootKey.copyOf()159 val payload = VaultPayload(160 entries = emptyList(),161 duressConfigured = false,162 rootKeyB64 = b64(sessionRootKey),163 recoveryWordHash = null,164 recoveryWordsSet = false,165 )166 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))167 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)168 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")169 writeMatchingNoiseSlotB(slotA)170 store.stampLocalMutation()171 store.writeSettings(_settings.value)172173 val previousDerivedKey = derivedKey174 val previousRootKey = rootKey175 derivedKey = nextDerivedKey176 rootKey = sessionRootKey177 nextDerivedKey = null178 sessionRootKey = null179 entryList = mutableListOf()180 duressConfigured = false181 recoveryWordHash = null182 recoveryWordsSet = false183 decoyMode = false184 publish()185 _isUnlocked.value = true186 previousDerivedKey?.let(VaultCrypto::zero)187 previousRootKey?.let(VaultCrypto::zero)188 AdoptVaultResult.Success(vaultId)189 } catch (e: Exception) {190 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")191 } finally {192 adoptedRootKey?.let(VaultCrypto::zero)193 nextDerivedKey?.let(VaultCrypto::zero)194 sessionRootKey?.let(VaultCrypto::zero)195 }196 }197198 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }199200 201 private fun unlockLocked(password: String): UnlockResult {202 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")203 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))204 val payload = try {205 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))206 } catch (_: Exception) {207 VaultCrypto.zero(key)208 209 210 211 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {212 val legacy = tryLegacyRawUnlock(password, slotA)213 if (legacy != null) return legacy214 }215 return UnlockResult.WrongPassword216 }217 return adoptSlotA(key, payload)218 }219220 221222223224225226 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {227 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))228 val payload = try {229 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))230 } catch (_: Exception) {231 VaultCrypto.zero(rawKey)232 return null233 }234 235 236 val newSalt = VaultCrypto.generateSalt()237 val newKey = VaultCrypto.deriveKey(password, newSalt)238 val healed = try {239 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))240 val next = StoredVault(241 version = slotA.version,242 salt = b64(newSalt),243 blob = blob,244 recoveryEscrow = slotA.recoveryEscrow,245 rootKeyEscrow = slotA.rootKeyEscrow,246 )247 if (store.writeSlotA(next)) {248 writeMatchingNoiseSlotB(next)249 store.stampLocalMutation()250 true251 } else {252 false253 }254 } catch (_: Exception) {255 false256 }257 return if (healed) {258 VaultCrypto.zero(rawKey)259 adoptSlotA(newKey, payload) 260 } else {261 VaultCrypto.zero(newKey)262 adoptSlotA(rawKey, payload) 263 }264 }265266 267 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {268 return try {269 derivedKey = key270 decoyMode = false271 entryList = payload.entries.toMutableList()272 duressConfigured = payload.duressConfigured273 recoveryWordHash = payload.recoveryWordHash274 recoveryWordsSet = payload.recoveryWordsSet275 _settings.value = store.loadSettings()276 ensureRootKey(payload)277 autoPurgeExpiredTrash()278 publish()279 _isUnlocked.value = true280 UnlockResult.Success281 } catch (e: Exception) {282 UnlockResult.Error(e.message ?: "unlock_failed")283 }284 }285286 287288 289290291292293294 class SlotAUnlock internal constructor(295 internal val key: ByteArray,296 internal val payload: VaultPayload,297 )298299 300301302303304305 fun tryUnlockSlotA(password: String): SlotAUnlock? {306 val slotA = store.loadSlotA() ?: return null307 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))308 return try {309 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))310 SlotAUnlock(key, payload)311 } catch (_: Exception) {312 VaultCrypto.zero(key)313 null314 }315 }316317 318 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {319 adoptSlotA(unlock.key, unlock.payload)320 }321322 323 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)324325 326327 328329330331332 class DuressUnlock internal constructor(333 internal val key: ByteArray,334 internal val payload: VaultPayload,335 )336337 338339340341342343344 fun tryUnlockDuress(password: String): DuressUnlock? {345 val slot = store.loadDecoySlot() ?: return null346 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))347 return try {348 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))349 DuressUnlock(key, payload)350 } catch (_: Exception) {351 VaultCrypto.zero(key)352 null353 }354 }355356 357 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {358 return try {359 derivedKey = unlock.key360 decoyMode = true361 entryList = unlock.payload.entries.toMutableList()362 duressConfigured = false363 recoveryWordHash = unlock.payload.recoveryWordHash364 recoveryWordsSet = unlock.payload.recoveryWordsSet365 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)366 else VaultCrypto.generateRootKey()367 _settings.value = store.loadSettings()368 publish()369 _isUnlocked.value = true370 UnlockResult.Success371 } catch (e: Exception) {372 UnlockResult.Error(e.message ?: "decoy_unlock_failed")373 }374 }375376 377 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)378379 380381382383384385 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {386 if (derivedKey == null || decoyMode) return false387 388 389 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }390 return try {391 val salt = VaultCrypto.generateSalt()392 val key = VaultCrypto.deriveKey(duressPassphrase, salt)393 394 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))395 val plaintext = VaultCodec.encode(decoyPayload)396 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))397 VaultCrypto.zero(key)398 VaultCrypto.zero(plaintext)399 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))400 if (ok) {401 duressConfigured = true402 runCatching { persistSlotA(bumpVersion = false) }403 }404 ok405 } catch (_: Exception) {406 false407 }408 }409410 411 suspend fun clearDuress(): Boolean = mutex.withLock {412 store.clearDecoySlot()413 if (derivedKey != null && !decoyMode) {414 duressConfigured = false415 runCatching { persistSlotA(bumpVersion = false) }416 }417 true418 }419420 421 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {422 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")423 return try {424 val payload = VaultCodec.decode(425 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),426 )427 derivedKey = key.copyOf()428 decoyMode = false429 entryList = payload.entries.toMutableList()430 duressConfigured = payload.duressConfigured431 recoveryWordHash = payload.recoveryWordHash432 recoveryWordsSet = payload.recoveryWordsSet433 _settings.value = store.loadSettings()434 ensureRootKey(payload)435 autoPurgeExpiredTrash()436 publish()437 _isUnlocked.value = true438 UnlockResult.Success439 } catch (_: Exception) {440 UnlockResult.WrongPassword441 }442 }443444 fun lock() {445 derivedKey?.let(VaultCrypto::zero)446 rootKey?.let(VaultCrypto::zero)447 derivedKey = null448 rootKey = null449 entryList = mutableListOf()450 duressConfigured = false451 recoveryWordHash = null452 recoveryWordsSet = false453 decoyMode = false454 _entries.value = emptyList()455 _isUnlocked.value = false456 }457458 @Volatile private var suppressNextBackgroundLock = false459460 461462463464465466467468469 fun expectTransientBackground() {470 suppressNextBackgroundLock = true471 }472473 474 fun consumeTransientBackgroundSuppression(): Boolean {475 val suppressed = suppressNextBackgroundLock476 suppressNextBackgroundLock = false477 return suppressed478 }479480 481482 483484485486487 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()488489 490 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }491492 493494495496497 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {498 val key = derivedKey ?: return false499 if (decoyMode) return false500 val priorSlot = store.loadSlotA() ?: return false501 val priorRootKey = rootKey ?: return false502 val priorRecoveryHash = recoveryWordHash503 val priorRecoverySet = recoveryWordsSet504 val nextRootKey = generateRecoverableRootKey()505 val nextWords = rootKeyToWords(nextRootKey)506507 try {508 takeVaultSnapshot("local_save")509 rootKey = nextRootKey510 recoveryWordHash = recoveryWordsHash(nextWords)511 recoveryWordsSet = false512513 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)514 ?: throw IllegalStateException("recovery_escrow_failed")515 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)516 ?: throw IllegalStateException("root_escrow_failed")517 val plaintext = VaultCodec.encode(currentPayload())518 val blob = try {519 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))520 } finally {521 VaultCrypto.zero(plaintext)522 }523 val nextSlot = priorSlot.copy(524 blob = blob,525 recoveryEscrow = nextRecoveryEscrow,526 rootKeyEscrow = nextRootKeyEscrow,527 )528 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")529 try {530 writeMatchingNoiseSlotB(nextSlot)531 store.stampLocalMutation()532 } catch (e: Exception) {533 store.writeSlotA(priorSlot)534 throw e535 }536 VaultCrypto.zero(priorRootKey)537 publish()538 true539 } catch (_: Exception) {540 rootKey = priorRootKey541 recoveryWordHash = priorRecoveryHash542 recoveryWordsSet = priorRecoverySet543 VaultCrypto.zero(nextRootKey)544 store.writeSlotA(priorSlot)545 false546 }547 }548549 550551552553554 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()555556 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {557 val slotA = store.loadSlotA() ?: return false558 return try {559 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))560 val payload = VaultCodec.decode(561 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),562 )563 val newSalt = VaultCrypto.generateSalt()564 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)565 566 val plaintext = VaultCodec.encode(payload)567 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))568 569 570 571 572 573 574 575 val next = StoredVault(576 version = slotA.version,577 salt = b64(newSalt),578 blob = blob,579 recoveryEscrow = slotA.recoveryEscrow,580 rootKeyEscrow = slotA.rootKeyEscrow,581 )582 if (!store.writeSlotA(next)) return false583 writeMatchingNoiseSlotB(next)584 store.stampLocalMutation()585 VaultCrypto.zero(oldKey)586 derivedKey?.let(VaultCrypto::zero)587 derivedKey = newKey588 true589 } catch (_: Exception) {590 false591 }592 }593594 595596597598 suspend fun destroyVault() = mutex.withLock {599 lock()600 store.clearVault()601 store.clearDecoySlot()602 }603604 605606 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {607 requireUnlocked()608 val stamp = VaultCrypto.monotonicEntryTimestamp()609 val now = System.currentTimeMillis()610 val entry = withDerivedLast4(draft.copy(611 id = draft.id.ifBlank { UUID.randomUUID().toString() },612 createdAt = if (draft.createdAt > 0) draft.createdAt else now,613 modifiedAt = stamp,614 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,615 ))616 val rollback = entryList.toMutableList()617 entryList.add(entry)618 persistOrRollback(rollback)619 entry620 }621622 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {623 requireUnlocked()624 val idx = entryList.indexOfFirst { it.id == patch.id }625 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")626 val current = entryList[idx]627 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)628 629 val passwordChanged = patch.password != null && patch.password != current.password630 631 632 633 634 635 636 val priorPassword = current.password637 val shouldCapture = passwordChanged &&638 !priorPassword.isNullOrEmpty() &&639 current.passwordHistory?.firstOrNull()?.password != priorPassword640 val nextHistory = if (shouldCapture) {641 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +642 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)643 } else {644 current.passwordHistory645 }646 val updated = withDerivedLast4(patch.copy(647 createdAt = current.createdAt,648 modifiedAt = stamp,649 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,650 passwordHistory = nextHistory,651 ))652 val rollback = entryList.toMutableList()653 entryList[idx] = updated654 persistOrRollback(rollback)655 updated656 }657658 659 suspend fun deleteEntry(id: String) = mutex.withLock {660 requireUnlocked()661 val idx = entryList.indexOfFirst { it.id == id }662 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")663 val current = entryList[idx]664 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)665 val rollback = entryList.toMutableList()666 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)667 persistOrRollback(rollback)668 }669670 suspend fun restoreEntry(id: String) = mutex.withLock {671 requireUnlocked()672 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }673 if (idx == -1) throw IllegalStateException("deleted entry not found")674 val current = entryList[idx]675 val rollback = entryList.toMutableList()676 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))677 persistOrRollback(rollback)678 }679680 681 suspend fun purgeEntry(id: String) = mutex.withLock {682 requireUnlocked()683 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }684 if (idx == -1) throw IllegalStateException("deleted entry not found")685 val rollback = entryList.toMutableList()686 entryList.removeAt(idx)687 persistOrRollback(rollback)688 }689690 691 suspend fun purgeDeletedEntries(): Int = mutex.withLock {692 requireUnlocked()693 val rollback = entryList.toMutableList()694 val before = entryList.size695 entryList.removeAll { it.deletedAt != null }696 val removed = before - entryList.size697 if (removed > 0) persistOrRollback(rollback)698 removed699 }700701 702 suspend fun countDuplicateEntries(): Int = mutex.withLock {703 requireUnlocked()704 countDuplicateEntriesLocked()705 }706707 708709710711712 suspend fun deduplicateEntries(): Int = mutex.withLock {713 requireUnlocked()714 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")715 val active = entryList.filter { it.deletedAt == null }716 val keepIds = mutableSetOf<String>()717 active.groupBy { it.duplicateKey() }.values.forEach { group ->718 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id719 }720721 val rollback = entryList.toMutableList()722 var removed = 0723 entryList = entryList.map { entry ->724 if (entry.deletedAt == null && entry.id !in keepIds) {725 removed++726 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)727 entry.copy(deletedAt = stamp, modifiedAt = stamp)728 } else {729 entry730 }731 }.toMutableList()732 if (removed > 0) persistOrRollback(rollback)733 removed734 }735736 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }737 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }738 fun getDeletedEntries(): List<VaultEntry> =739 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }740741 742 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {743 val active = entryList.filter { it.deletedAt == null }744 if (tagFilter == null) return active745 val needle = tagFilter.trim().lowercase()746 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }747 }748749 750 fun getDistinctTags(): List<String> =751 entryList.asSequence()752 .filter { it.deletedAt == null }753 .flatMap { it.tags?.asSequence() ?: emptySequence() }754 .map { it.trim().lowercase() }755 .filter { it.isNotEmpty() }756 .distinct()757 .sorted()758 .toList()759760 761762763764765766 fun getDistinctFieldValues(field: String): List<String> {767 val select: (VaultEntry) -> String? = when (field) {768 "username" -> { e -> e.username }769 "email" -> { e -> e.email }770 else -> return emptyList()771 }772 return entryList.asSequence()773 .filter { it.deletedAt == null }774 .mapNotNull { select(it)?.trim() }775 .filter { it.isNotEmpty() }776 .groupingBy { it.lowercase() }777 .aggregate { _, acc: Pair<String, Int>?, value, _ ->778 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)779 }780 .values781 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })782 .map { it.first }783 }784785 fun getTombstones(): List<Tombstone> =786 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }787 fun getVaultVersion(): Long = store.version()788 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots789790 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {791 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false792 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false793 takeVaultSnapshot("restore")794 if (!store.writeSlotA(restoredA)) return false795 if (!store.writeSlotB(snapshot.vault_b)) return false796 store.stampLocalMutation()797 lock()798 true799 }800801 802803 804805806807808 suspend fun applyMergedRemoteEntries(809 remoteEntries: List<VaultEntry>,810 remoteTombstones: List<Tombstone>,811 ): Int = mutex.withLock {812 requireUnlocked()813 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }814 var applied = 0815 val byId = entryList.associateBy { it.id }.toMutableMap()816817 for (remote in remoteEntries) {818 val tomb = tombMap[remote.id]819 if (tomb != null && tomb >= remote.modifiedAt) continue820 val local = byId[remote.id]821 if (local == null) {822 entryList.add(remote); byId[remote.id] = remote; applied++823 } else if (remote.modifiedAt > local.modifiedAt) {824 val i = entryList.indexOfFirst { it.id == local.id }825 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }826 }827 }828829 for ((id, deletedAt) in tombMap) {830 val local = byId[id] ?: continue831 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue832 if (deletedAt < local.modifiedAt) continue833 val i = entryList.indexOfFirst { it.id == id }834 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }835 }836837 if (applied > 0) {838 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")839 publish()840 }841 applied842 }843844 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {845 _settings.value = settings846 store.writeSettings(settings)847 }848849 850851 852 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet853854 855856857858859 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {860 requireUnlocked()861 recoveryWordHash = recoveryWordsHash(words)862 recoveryWordsSet = true863 864 865 866 867 runCatching {868 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))869 }.getOrDefault(false)870 }871872 873 fun verifyRecoveryWords(words: List<String>): Boolean {874 val stored = recoveryWordHash ?: return false875 return stored == recoveryWordsHash(words)876 }877878 879880 881 fun hasRecoveryEscrow(): Boolean =882 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false883884 885886887888889890891892893894895896 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {897 val slotA = store.loadSlotA() ?: return false898 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false899900 901 902 slotA.recoveryEscrow?.let { escrow ->903 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let904 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))905 val keyBytes = recoveryKey.encoded906 try {907 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)908 val payload = VaultCodec.decode(plaintext)909 decoyMode = false910 entryList = payload.entries.toMutableList()911 duressConfigured = payload.duressConfigured912 recoveryWordHash = payload.recoveryWordHash913 recoveryWordsSet = payload.recoveryWordsSet914 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)915 else generateRecoverableRootKey()916 _settings.value = store.loadSettings()917 VaultCrypto.zero(plaintext)918 return true919 } catch (_: Exception) {920 921 } finally {922 VaultCrypto.zero(material)923 VaultCrypto.zero(keyBytes)924 }925 }926927 928 929 930 slotA.rootKeyEscrow?.let { escrow ->931 val rk = unsealRootKeyEscrow(words, escrow) ?: return false932 decoyMode = false933 entryList = mutableListOf()934 duressConfigured = false935 recoveryWordHash = recoveryWordsHash(words)936 recoveryWordsSet = true937 rootKey = rk938 _settings.value = store.loadSettings()939 return true940 }941 return false942 }943944 945946947948949950951952953 suspend fun resetPasswordWithRecoveryWords(954 words: List<String>,955 newPassword: String,956 ): Boolean = mutex.withLock {957 if (rootKey == null) return false 958 return try {959 val newSalt = VaultCrypto.generateSalt()960 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)961 val plaintext = VaultCodec.encode(currentPayload())962 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))963 val prior = store.loadSlotA()964 965 966 967 val valid = words.size == 12 && findInvalidWords(words).isEmpty()968 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow969 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow970 val next = StoredVault(971 version = prior?.version ?: 1,972 salt = b64(newSalt),973 blob = blob,974 recoveryEscrow = freshRecovery,975 rootKeyEscrow = freshRootKey,976 )977 if (!store.writeSlotA(next)) return false978 writeMatchingNoiseSlotB(next)979 store.stampLocalMutation()980 derivedKey?.let(VaultCrypto::zero)981 derivedKey = newKey982 VaultCrypto.zero(plaintext)983 publish()984 _isUnlocked.value = true985 true986 } catch (_: Exception) {987 false988 }989 }990991 992993994995996997 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {998 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null999 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1000 val salt = VaultCrypto.generateSalt()1001 val recoveryKey = deriveRecoveryKey(material, salt)1002 val keyBytes = recoveryKey.encoded1003 val plaintext = VaultCodec.encode(currentPayload())1004 return try {1005 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1006 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1007 } finally {1008 VaultCrypto.zero(material)1009 VaultCrypto.zero(keyBytes)1010 VaultCrypto.zero(plaintext)1011 }1012 }10131014 10151016101710181019102010211022 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1023 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1024 val rk = rootKey ?: return null1025 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1026 val salt = VaultCrypto.generateSalt()1027 val recoveryKey = deriveRecoveryKey(material, salt)1028 val keyBytes = recoveryKey.encoded1029 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1030 return try {1031 1032 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1033 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1034 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1035 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1036 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1037 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1038 } finally {1039 VaultCrypto.zero(material)1040 VaultCrypto.zero(keyBytes)1041 VaultCrypto.zero(plaintext)1042 }1043 }10441045 1046 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1047 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1048 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1049 val keyBytes = recoveryKey.encoded1050 return try {1051 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1052 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1053 hexToRootKey(String(pt, Charsets.US_ASCII))1054 } catch (_: Exception) {1055 null1056 } finally {1057 VaultCrypto.zero(material)1058 VaultCrypto.zero(keyBytes)1059 }1060 }10611062 106310641065106610671068 private fun persistSlotAWithEscrows(1069 recoveryEscrow: RecoveryEscrow?,1070 rootKeyEscrow: RootKeyEscrow?,1071 ): Boolean {1072 val key = derivedKey ?: throw IllegalStateException("vault_locked")1073 if (decoyMode) return false1074 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1075 val plaintext = VaultCodec.encode(currentPayload())1076 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1077 val next = prior.copy(1078 blob = blob,1079 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1080 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1081 )1082 if (!store.writeSlotA(next)) return false1083 try {1084 writeMatchingNoiseSlotB(next)1085 } catch (e: Exception) {1086 store.writeSlotA(prior)1087 throw e1088 }1089 return true1090 }10911092 10931094 private fun requireUnlocked() {1095 if (derivedKey == null) throw IllegalStateException("vault_locked")1096 }10971098 private fun countDuplicateEntriesLocked(): Int {1099 val seen = mutableMapOf<String, VaultEntry>()1100 var duplicates = 01101 for (entry in entryList.filter { it.deletedAt == null }) {1102 val key = entry.duplicateKey()1103 val existing = seen[key]1104 if (existing == null) {1105 seen[key] = entry1106 } else {1107 duplicates++1108 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1109 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1110 if (entryStamp > existingStamp) seen[key] = entry1111 }1112 }1113 return duplicates1114 }11151116 private fun VaultEntry.duplicateKey(): String {1117 if (type == "note") return "note|${title.lowercase()}"1118 val passkeyData = passkey1119 if (passkeyData != null) {1120 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1121 }1122 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1123 }11241125 private fun takeVaultSnapshot(reason: String) {1126 runCatching {1127 val slotA = store.loadSlotA() ?: return1128 val vaultA = VaultCodec.encodeSlot(slotA)1129 val vaultB = store.loadSlotB() ?: slotA.blob1130 val history = store.loadVaultHistory()1131 val latest = history.snapshots.firstOrNull()1132 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1133 val entryCount = if (derivedKey != null && !decoyMode) {1134 bucketEntryCount(entryList.count { it.deletedAt == null })1135 } else {1136 null1137 }1138 val snapshot = VaultSnapshot(1139 id = UUID.randomUUID().toString(),1140 savedAt = System.currentTimeMillis(),1141 reason = reason,1142 vault_a = vaultA,1143 vault_b = vaultB,1144 entryCount = entryCount,1145 )1146 store.writeVaultHistory(1147 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1148 )1149 }1150 }11511152 private fun bucketEntryCount(n: Int): Int = when {1153 n <= 0 -> 01154 n <= 10 -> 101155 n <= 25 -> 251156 n <= 50 -> 501157 n <= 100 -> 1001158 n <= 250 -> 2501159 n <= 500 -> 5001160 else -> ((n + 499) / 500) * 5001161 }11621163 11641165116611671168 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1169 if (!entry.isCard()) return entry1170 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1171 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1172 }11731174 117511761177117811791180 private fun autoPurgeExpiredTrash() {1181 val days = _settings.value.trashRetentionDays ?: return1182 if (days <= 0) return1183 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1184 val before = entryList.size1185 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1186 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1187 }11881189 1190 private fun currentPayload(): VaultPayload =1191 VaultPayload(1192 entries = entryList.toList(),1193 duressConfigured = duressConfigured,1194 rootKeyB64 = rootKey?.let(::b64) ?: "",1195 recoveryWordHash = recoveryWordHash,1196 recoveryWordsSet = recoveryWordsSet,1197 )11981199 12001201120212031204 private fun ensureRootKey(payload: VaultPayload) {1205 if (payload.rootKeyB64.isNotBlank()) {1206 rootKey = unb64(payload.rootKeyB64)1207 return1208 }1209 rootKey = generateRecoverableRootKey()1210 runCatching { persistSlotA(bumpVersion = false) }1211 }12121213 private fun publish() {1214 _entries.value = entryList.filter { it.deletedAt == null }1215 }12161217 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1218 val plaintext = VaultCodec.encode(currentPayload())1219 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1220 return StoredVault(version = 1, salt = b64(salt), blob = blob)1221 }12221223 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1224 1225 1226 1227 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1228 val noise = ByteArray(approxBytes).also(rng::nextBytes)1229 store.writeSlotB(b64(noise))1230 }12311232 1233 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1234 val key = derivedKey ?: throw IllegalStateException("vault_locked")1235 1236 if (decoyMode) {1237 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1238 val plaintext = VaultCodec.encode(currentPayload())1239 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1240 return store.writeDecoySlot(prior.copy(blob = blob))1241 }1242 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1243 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1244 val plaintext = VaultCodec.encode(currentPayload())1245 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1246 val next = prior.copy(blob = blob)1247 if (!store.writeSlotA(next)) return false1248 1249 1250 try {1251 writeMatchingNoiseSlotB(next)1252 if (bumpVersion) store.stampLocalMutation()1253 } catch (e: Exception) {1254 store.writeSlotA(prior)1255 throw e1256 }1257 return true1258 }12591260 1261 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1262 try {1263 if (!persistSlotA(bumpVersion = true)) {1264 entryList = rollback1265 throw IllegalStateException("storage_write_failed")1266 }1267 } catch (e: Exception) {1268 entryList = rollback1269 throw e1270 }1271 publish()1272 }1273}