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 = 502021sealed interface UnlockResult {22 data object Success : UnlockResult23 data object WrongPassword : UnlockResult24 data class Error(val reason: String) : UnlockResult25}2627sealed interface CreateVaultResult {28 data object Success : CreateVaultResult29 data class Error(val reason: String) : CreateVaultResult30}313233343536373839404142434445@Singleton46class VaultRepository @Inject constructor(47 private val store: VaultStorage,48) {49 private val mutex = Mutex()50 private val rng = SecureRandom()5152 private var derivedKey: ByteArray? = null53 private var rootKey: ByteArray? = null54 private var entryList: MutableList<VaultEntry> = mutableListOf()55 private var duressConfigured: Boolean = false56 private var recoveryWordHash: String? = null57 private var recoveryWordsSet: Boolean = false58 59 private var decoyMode: Boolean = false6061 private val _isUnlocked = MutableStateFlow(false)62 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6364 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())65 66 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6768 private val _settings = MutableStateFlow(VaultSettings())69 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7071 fun hasVault(): Boolean = store.hasVault()7273 7475 7677787980 fun codexVaultBlobBase64(): String? {81 val slotA = store.loadSlotA() ?: return null82 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""83 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))84 }8586 8788899091 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {92 return try {93 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)94 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)95 ?: return UnlockResult.Error("invalid_codex_blob")96 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")97 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")98 writeMatchingNoiseSlotB(slotA)99 store.stampLocalMutation()100 unlockLocked(password)101 } catch (e: Exception) {102 UnlockResult.Error(e.message ?: "restore_failed")103 }104 }105106 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {107 return try {108 val salt = VaultCrypto.generateSalt()109 val key = VaultCrypto.deriveKey(password, salt)110 entryList = mutableListOf()111 duressConfigured = false112 recoveryWordHash = null113 recoveryWordsSet = false114 decoyMode = false115 rootKey = generateRecoverableRootKey()116 val slotA = sealSlotA(key, salt)117 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")118 writeMatchingNoiseSlotB(slotA)119 store.stampLocalMutation()120 store.writeSettings(_settings.value)121 derivedKey = key122 publish()123 _isUnlocked.value = true124 CreateVaultResult.Success125 } catch (e: Exception) {126 CreateVaultResult.Error(e.message ?: "create_failed")127 }128 }129130 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }131132 133 private fun unlockLocked(password: String): UnlockResult {134 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")135 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))136 val payload = try {137 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))138 } catch (_: Exception) {139 VaultCrypto.zero(key)140 141 142 143 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {144 val legacy = tryLegacyRawUnlock(password, slotA)145 if (legacy != null) return legacy146 }147 return UnlockResult.WrongPassword148 }149 return adoptSlotA(key, payload)150 }151152 153154155156157158 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {159 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))160 val payload = try {161 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))162 } catch (_: Exception) {163 VaultCrypto.zero(rawKey)164 return null165 }166 167 168 val newSalt = VaultCrypto.generateSalt()169 val newKey = VaultCrypto.deriveKey(password, newSalt)170 val healed = try {171 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))172 val next = StoredVault(173 version = slotA.version,174 salt = b64(newSalt),175 blob = blob,176 recoveryEscrow = slotA.recoveryEscrow,177 rootKeyEscrow = slotA.rootKeyEscrow,178 )179 if (store.writeSlotA(next)) {180 writeMatchingNoiseSlotB(next)181 store.stampLocalMutation()182 true183 } else {184 false185 }186 } catch (_: Exception) {187 false188 }189 return if (healed) {190 VaultCrypto.zero(rawKey)191 adoptSlotA(newKey, payload) 192 } else {193 VaultCrypto.zero(newKey)194 adoptSlotA(rawKey, payload) 195 }196 }197198 199 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {200 return try {201 derivedKey = key202 decoyMode = false203 entryList = payload.entries.toMutableList()204 duressConfigured = payload.duressConfigured205 recoveryWordHash = payload.recoveryWordHash206 recoveryWordsSet = payload.recoveryWordsSet207 _settings.value = store.loadSettings()208 ensureRootKey(payload)209 autoPurgeExpiredTrash()210 publish()211 _isUnlocked.value = true212 UnlockResult.Success213 } catch (e: Exception) {214 UnlockResult.Error(e.message ?: "unlock_failed")215 }216 }217218 219220 221222223224225226 class SlotAUnlock internal constructor(227 internal val key: ByteArray,228 internal val payload: VaultPayload,229 )230231 232233234235236237 fun tryUnlockSlotA(password: String): SlotAUnlock? {238 val slotA = store.loadSlotA() ?: return null239 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))240 return try {241 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))242 SlotAUnlock(key, payload)243 } catch (_: Exception) {244 VaultCrypto.zero(key)245 null246 }247 }248249 250 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {251 adoptSlotA(unlock.key, unlock.payload)252 }253254 255 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)256257 258259 260261262263264 class DuressUnlock internal constructor(265 internal val key: ByteArray,266 internal val payload: VaultPayload,267 )268269 270271272273274275276 fun tryUnlockDuress(password: String): DuressUnlock? {277 val slot = store.loadDecoySlot() ?: return null278 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))279 return try {280 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))281 DuressUnlock(key, payload)282 } catch (_: Exception) {283 VaultCrypto.zero(key)284 null285 }286 }287288 289 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {290 return try {291 derivedKey = unlock.key292 decoyMode = true293 entryList = unlock.payload.entries.toMutableList()294 duressConfigured = false295 recoveryWordHash = unlock.payload.recoveryWordHash296 recoveryWordsSet = unlock.payload.recoveryWordsSet297 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)298 else VaultCrypto.generateRootKey()299 _settings.value = store.loadSettings()300 publish()301 _isUnlocked.value = true302 UnlockResult.Success303 } catch (e: Exception) {304 UnlockResult.Error(e.message ?: "decoy_unlock_failed")305 }306 }307308 309 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)310311 312313314315316317 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {318 if (derivedKey == null || decoyMode) return false319 320 321 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }322 return try {323 val salt = VaultCrypto.generateSalt()324 val key = VaultCrypto.deriveKey(duressPassphrase, salt)325 326 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))327 val plaintext = VaultCodec.encode(decoyPayload)328 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))329 VaultCrypto.zero(key)330 VaultCrypto.zero(plaintext)331 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))332 if (ok) {333 duressConfigured = true334 runCatching { persistSlotA(bumpVersion = false) }335 }336 ok337 } catch (_: Exception) {338 false339 }340 }341342 343 suspend fun clearDuress(): Boolean = mutex.withLock {344 store.clearDecoySlot()345 if (derivedKey != null && !decoyMode) {346 duressConfigured = false347 runCatching { persistSlotA(bumpVersion = false) }348 }349 true350 }351352 353 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {354 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")355 return try {356 val payload = VaultCodec.decode(357 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),358 )359 derivedKey = key.copyOf()360 decoyMode = false361 entryList = payload.entries.toMutableList()362 duressConfigured = payload.duressConfigured363 recoveryWordHash = payload.recoveryWordHash364 recoveryWordsSet = payload.recoveryWordsSet365 _settings.value = store.loadSettings()366 ensureRootKey(payload)367 autoPurgeExpiredTrash()368 publish()369 _isUnlocked.value = true370 UnlockResult.Success371 } catch (_: Exception) {372 UnlockResult.WrongPassword373 }374 }375376 fun lock() {377 derivedKey?.let(VaultCrypto::zero)378 rootKey?.let(VaultCrypto::zero)379 derivedKey = null380 rootKey = null381 entryList = mutableListOf()382 duressConfigured = false383 recoveryWordHash = null384 recoveryWordsSet = false385 decoyMode = false386 _entries.value = emptyList()387 _isUnlocked.value = false388 }389390 391392 393394395396397 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()398399 400 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }401402 403404405406407 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()408409 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {410 val slotA = store.loadSlotA() ?: return false411 return try {412 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))413 val payload = VaultCodec.decode(414 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),415 )416 val newSalt = VaultCrypto.generateSalt()417 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)418 419 val plaintext = VaultCodec.encode(payload)420 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))421 422 423 424 425 426 427 428 val next = StoredVault(429 version = slotA.version,430 salt = b64(newSalt),431 blob = blob,432 recoveryEscrow = slotA.recoveryEscrow,433 rootKeyEscrow = slotA.rootKeyEscrow,434 )435 if (!store.writeSlotA(next)) return false436 writeMatchingNoiseSlotB(next)437 store.stampLocalMutation()438 VaultCrypto.zero(oldKey)439 derivedKey?.let(VaultCrypto::zero)440 derivedKey = newKey441 true442 } catch (_: Exception) {443 false444 }445 }446447 448449450451 suspend fun destroyVault() = mutex.withLock {452 lock()453 store.clearVault()454 store.clearDecoySlot()455 }456457 458459 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {460 requireUnlocked()461 val stamp = VaultCrypto.monotonicEntryTimestamp()462 val now = System.currentTimeMillis()463 val entry = withDerivedLast4(draft.copy(464 id = draft.id.ifBlank { UUID.randomUUID().toString() },465 createdAt = if (draft.createdAt > 0) draft.createdAt else now,466 modifiedAt = stamp,467 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,468 ))469 val rollback = entryList.toMutableList()470 entryList.add(entry)471 persistOrRollback(rollback)472 entry473 }474475 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {476 requireUnlocked()477 val idx = entryList.indexOfFirst { it.id == patch.id }478 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")479 val current = entryList[idx]480 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)481 482 val passwordChanged = patch.password != null && patch.password != current.password483 484 485 486 487 488 489 val priorPassword = current.password490 val shouldCapture = passwordChanged &&491 !priorPassword.isNullOrEmpty() &&492 current.passwordHistory?.firstOrNull()?.password != priorPassword493 val nextHistory = if (shouldCapture) {494 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +495 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)496 } else {497 current.passwordHistory498 }499 val updated = withDerivedLast4(patch.copy(500 createdAt = current.createdAt,501 modifiedAt = stamp,502 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,503 passwordHistory = nextHistory,504 ))505 val rollback = entryList.toMutableList()506 entryList[idx] = updated507 persistOrRollback(rollback)508 updated509 }510511 512 suspend fun deleteEntry(id: String) = mutex.withLock {513 requireUnlocked()514 val idx = entryList.indexOfFirst { it.id == id }515 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")516 val current = entryList[idx]517 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)518 val rollback = entryList.toMutableList()519 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)520 persistOrRollback(rollback)521 }522523 suspend fun restoreEntry(id: String) = mutex.withLock {524 requireUnlocked()525 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }526 if (idx == -1) throw IllegalStateException("deleted entry not found")527 val current = entryList[idx]528 val rollback = entryList.toMutableList()529 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))530 persistOrRollback(rollback)531 }532533 534 suspend fun purgeEntry(id: String) = mutex.withLock {535 requireUnlocked()536 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }537 if (idx == -1) throw IllegalStateException("deleted entry not found")538 val rollback = entryList.toMutableList()539 entryList.removeAt(idx)540 persistOrRollback(rollback)541 }542543 544 suspend fun purgeDeletedEntries(): Int = mutex.withLock {545 requireUnlocked()546 val rollback = entryList.toMutableList()547 val before = entryList.size548 entryList.removeAll { it.deletedAt != null }549 val removed = before - entryList.size550 if (removed > 0) persistOrRollback(rollback)551 removed552 }553554 555 suspend fun countDuplicateEntries(): Int = mutex.withLock {556 requireUnlocked()557 countDuplicateEntriesLocked()558 }559560 561562563564565 suspend fun deduplicateEntries(): Int = mutex.withLock {566 requireUnlocked()567 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")568 val active = entryList.filter { it.deletedAt == null }569 val keepIds = mutableSetOf<String>()570 active.groupBy { it.duplicateKey() }.values.forEach { group ->571 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id572 }573574 val rollback = entryList.toMutableList()575 var removed = 0576 entryList = entryList.map { entry ->577 if (entry.deletedAt == null && entry.id !in keepIds) {578 removed++579 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)580 entry.copy(deletedAt = stamp, modifiedAt = stamp)581 } else {582 entry583 }584 }.toMutableList()585 if (removed > 0) persistOrRollback(rollback)586 removed587 }588589 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }590 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }591 fun getDeletedEntries(): List<VaultEntry> =592 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }593594 595 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {596 val active = entryList.filter { it.deletedAt == null }597 if (tagFilter == null) return active598 val needle = tagFilter.trim().lowercase()599 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }600 }601602 603 fun getDistinctTags(): List<String> =604 entryList.asSequence()605 .filter { it.deletedAt == null }606 .flatMap { it.tags?.asSequence() ?: emptySequence() }607 .map { it.trim().lowercase() }608 .filter { it.isNotEmpty() }609 .distinct()610 .sorted()611 .toList()612613 614615616617618619 fun getDistinctFieldValues(field: String): List<String> {620 val select: (VaultEntry) -> String? = when (field) {621 "username" -> { e -> e.username }622 "email" -> { e -> e.email }623 else -> return emptyList()624 }625 return entryList.asSequence()626 .filter { it.deletedAt == null }627 .mapNotNull { select(it)?.trim() }628 .filter { it.isNotEmpty() }629 .groupingBy { it.lowercase() }630 .aggregate { _, acc: Pair<String, Int>?, value, _ ->631 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)632 }633 .values634 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })635 .map { it.first }636 }637638 fun getTombstones(): List<Tombstone> =639 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }640 fun getVaultVersion(): Long = store.version()641642 643644 645646647648649 suspend fun applyMergedRemoteEntries(650 remoteEntries: List<VaultEntry>,651 remoteTombstones: List<Tombstone>,652 ): Int = mutex.withLock {653 requireUnlocked()654 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }655 var applied = 0656 val byId = entryList.associateBy { it.id }.toMutableMap()657658 for (remote in remoteEntries) {659 val tomb = tombMap[remote.id]660 if (tomb != null && tomb >= remote.modifiedAt) continue661 val local = byId[remote.id]662 if (local == null) {663 entryList.add(remote); byId[remote.id] = remote; applied++664 } else if (remote.modifiedAt > local.modifiedAt) {665 val i = entryList.indexOfFirst { it.id == local.id }666 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }667 }668 }669670 for ((id, deletedAt) in tombMap) {671 val local = byId[id] ?: continue672 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue673 if (deletedAt < local.modifiedAt) continue674 val i = entryList.indexOfFirst { it.id == id }675 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }676 }677678 if (applied > 0) {679 persistSlotA(bumpVersion = false)680 publish()681 }682 applied683 }684685 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {686 _settings.value = settings687 store.writeSettings(settings)688 }689690 691692 693 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet694695 696697698699700 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {701 requireUnlocked()702 recoveryWordHash = recoveryWordsHash(words)703 recoveryWordsSet = true704 705 706 707 708 runCatching {709 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))710 }.getOrDefault(false)711 }712713 714 fun verifyRecoveryWords(words: List<String>): Boolean {715 val stored = recoveryWordHash ?: return false716 return stored == recoveryWordsHash(words)717 }718719 720721 722 fun hasRecoveryEscrow(): Boolean =723 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false724725 726727728729730731732733734735736737 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {738 val slotA = store.loadSlotA() ?: return false739 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false740741 742 743 slotA.recoveryEscrow?.let { escrow ->744 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let745 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))746 val keyBytes = recoveryKey.encoded747 try {748 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)749 val payload = VaultCodec.decode(plaintext)750 decoyMode = false751 entryList = payload.entries.toMutableList()752 duressConfigured = payload.duressConfigured753 recoveryWordHash = payload.recoveryWordHash754 recoveryWordsSet = payload.recoveryWordsSet755 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)756 else generateRecoverableRootKey()757 _settings.value = store.loadSettings()758 VaultCrypto.zero(plaintext)759 return true760 } catch (_: Exception) {761 762 } finally {763 VaultCrypto.zero(material)764 VaultCrypto.zero(keyBytes)765 }766 }767768 769 770 771 slotA.rootKeyEscrow?.let { escrow ->772 val rk = unsealRootKeyEscrow(words, escrow) ?: return false773 decoyMode = false774 entryList = mutableListOf()775 duressConfigured = false776 recoveryWordHash = recoveryWordsHash(words)777 recoveryWordsSet = true778 rootKey = rk779 _settings.value = store.loadSettings()780 return true781 }782 return false783 }784785 786787788789790791792793794 suspend fun resetPasswordWithRecoveryWords(795 words: List<String>,796 newPassword: String,797 ): Boolean = mutex.withLock {798 if (rootKey == null) return false 799 return try {800 val newSalt = VaultCrypto.generateSalt()801 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)802 val plaintext = VaultCodec.encode(currentPayload())803 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))804 val prior = store.loadSlotA()805 806 807 808 val valid = words.size == 12 && findInvalidWords(words).isEmpty()809 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow810 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow811 val next = StoredVault(812 version = prior?.version ?: 1,813 salt = b64(newSalt),814 blob = blob,815 recoveryEscrow = freshRecovery,816 rootKeyEscrow = freshRootKey,817 )818 if (!store.writeSlotA(next)) return false819 writeMatchingNoiseSlotB(next)820 store.stampLocalMutation()821 derivedKey?.let(VaultCrypto::zero)822 derivedKey = newKey823 VaultCrypto.zero(plaintext)824 publish()825 _isUnlocked.value = true826 true827 } catch (_: Exception) {828 false829 }830 }831832 833834835836837838 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {839 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null840 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null841 val salt = VaultCrypto.generateSalt()842 val recoveryKey = deriveRecoveryKey(material, salt)843 val keyBytes = recoveryKey.encoded844 val plaintext = VaultCodec.encode(currentPayload())845 return try {846 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)847 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))848 } finally {849 VaultCrypto.zero(material)850 VaultCrypto.zero(keyBytes)851 VaultCrypto.zero(plaintext)852 }853 }854855 856857858859860861862863 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {864 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null865 val rk = rootKey ?: return null866 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null867 val salt = VaultCrypto.generateSalt()868 val recoveryKey = deriveRecoveryKey(material, salt)869 val keyBytes = recoveryKey.encoded870 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)871 return try {872 873 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)874 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)875 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)876 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)877 val ct = ctTag.copyOfRange(0, ctTag.size - 16)878 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))879 } finally {880 VaultCrypto.zero(material)881 VaultCrypto.zero(keyBytes)882 VaultCrypto.zero(plaintext)883 }884 }885886 887 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {888 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null889 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))890 val keyBytes = recoveryKey.encoded891 return try {892 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)893 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)894 hexToRootKey(String(pt, Charsets.US_ASCII))895 } catch (_: Exception) {896 null897 } finally {898 VaultCrypto.zero(material)899 VaultCrypto.zero(keyBytes)900 }901 }902903 904905906907908909 private fun persistSlotAWithEscrows(910 recoveryEscrow: RecoveryEscrow?,911 rootKeyEscrow: RootKeyEscrow?,912 ): Boolean {913 val key = derivedKey ?: throw IllegalStateException("vault_locked")914 if (decoyMode) return false915 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")916 val plaintext = VaultCodec.encode(currentPayload())917 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))918 val next = prior.copy(919 blob = blob,920 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,921 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,922 )923 if (!store.writeSlotA(next)) return false924 try {925 writeMatchingNoiseSlotB(next)926 } catch (e: Exception) {927 store.writeSlotA(prior)928 throw e929 }930 return true931 }932933 934935 private fun requireUnlocked() {936 if (derivedKey == null) throw IllegalStateException("vault_locked")937 }938939 private fun countDuplicateEntriesLocked(): Int {940 val seen = mutableMapOf<String, VaultEntry>()941 var duplicates = 0942 for (entry in entryList.filter { it.deletedAt == null }) {943 val key = entry.duplicateKey()944 val existing = seen[key]945 if (existing == null) {946 seen[key] = entry947 } else {948 duplicates++949 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt950 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt951 if (entryStamp > existingStamp) seen[key] = entry952 }953 }954 return duplicates955 }956957 private fun VaultEntry.duplicateKey(): String {958 if (type == "note") return "note|${title.lowercase()}"959 val passkeyData = passkey960 if (passkeyData != null) {961 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"962 }963 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"964 }965966 967968969970971 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {972 if (!entry.isCard()) return entry973 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""974 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)975 }976977 978979980981982983 private fun autoPurgeExpiredTrash() {984 val days = _settings.value.trashRetentionDays ?: return985 if (days <= 0) return986 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L987 val before = entryList.size988 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }989 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }990 }991992 993 private fun currentPayload(): VaultPayload =994 VaultPayload(995 entries = entryList.toList(),996 duressConfigured = duressConfigured,997 rootKeyB64 = rootKey?.let(::b64) ?: "",998 recoveryWordHash = recoveryWordHash,999 recoveryWordsSet = recoveryWordsSet,1000 )10011002 10031004100510061007 private fun ensureRootKey(payload: VaultPayload) {1008 if (payload.rootKeyB64.isNotBlank()) {1009 rootKey = unb64(payload.rootKeyB64)1010 return1011 }1012 rootKey = generateRecoverableRootKey()1013 runCatching { persistSlotA(bumpVersion = false) }1014 }10151016 private fun publish() {1017 _entries.value = entryList.filter { it.deletedAt == null }1018 }10191020 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1021 val plaintext = VaultCodec.encode(currentPayload())1022 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1023 return StoredVault(version = 1, salt = b64(salt), blob = blob)1024 }10251026 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1027 1028 1029 1030 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1031 val noise = ByteArray(approxBytes).also(rng::nextBytes)1032 store.writeSlotB(b64(noise))1033 }10341035 1036 private fun persistSlotA(bumpVersion: Boolean): Boolean {1037 val key = derivedKey ?: throw IllegalStateException("vault_locked")1038 1039 if (decoyMode) {1040 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1041 val plaintext = VaultCodec.encode(currentPayload())1042 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1043 return store.writeDecoySlot(prior.copy(blob = blob))1044 }1045 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1046 val plaintext = VaultCodec.encode(currentPayload())1047 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1048 val next = prior.copy(blob = blob)1049 if (!store.writeSlotA(next)) return false1050 1051 1052 try {1053 writeMatchingNoiseSlotB(next)1054 if (bumpVersion) store.stampLocalMutation()1055 } catch (e: Exception) {1056 store.writeSlotA(prior)1057 throw e1058 }1059 return true1060 }10611062 1063 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1064 try {1065 if (!persistSlotA(bumpVersion = true)) {1066 entryList = rollback1067 throw IllegalStateException("storage_write_failed")1068 }1069 } catch (e: Exception) {1070 entryList = rollback1071 throw e1072 }1073 publish()1074 }1075}