1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import kotlinx.serialization.json.Json11import kotlinx.serialization.json.JsonElement12import kotlinx.serialization.json.JsonNull13import kotlinx.serialization.json.JsonObject14import kotlinx.serialization.json.JsonPrimitive15import java.util.Base6416import java.util.UUID17import javax.inject.Inject18import javax.inject.Singleton192021private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }2223private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)24private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)252627private const val PASSWORD_HISTORY_MAX = 5028private const val VAULT_HISTORY_MAX = 52930sealed interface UnlockResult {31 data object Success : UnlockResult32 data object WrongPassword : UnlockResult33 data class Error(val reason: String) : UnlockResult34}3536sealed interface CreateVaultResult {37 data object Success : CreateVaultResult38 data class Error(val reason: String) : CreateVaultResult39}4041sealed interface AdoptVaultResult {42 data class Success(val vaultId: String) : AdoptVaultResult43 data class Error(val reason: String) : AdoptVaultResult44}454647484950515253545556575859@Singleton60class VaultRepository @Inject constructor(61 private val store: VaultStorage,62) {63 private val mutex = Mutex()64 private val rng = SecureRandom()6566 private var derivedKey: ByteArray? = null67 private var rootKey: ByteArray? = null68 private var entryList: MutableList<VaultEntry> = mutableListOf()69 private var duressConfigured: Boolean = false70 private var recoveryWordHash: String? = null71 private var recoveryWordsSet: Boolean = false72 73 private var decoyMode: Boolean = false7475 private val _isUnlocked = MutableStateFlow(false)76 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7778 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())79 80 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8182 private val _settings = MutableStateFlow(VaultSettings())83 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8485 fun hasVault(): Boolean = store.hasVault()8687 8889 9091929394 fun codexVaultBlobBase64(): String? {95 val slotA = store.loadSlotA() ?: return null96 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""97 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))98 }99100 101102103104105 106 private fun JsonElement?.asSlotJson(): String? = when {107 this == null || this is JsonNull -> null108 this is JsonPrimitive && isString -> content109 else -> toString()110 }111112 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {113 return try {114 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)115 116 117 118 119 120 121 122 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject123 ?: return UnlockResult.Error("invalid_codex_blob")124 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")125 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")126 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")127 128 129 val slotB = wrapper["b"].asSlotJson()130 if (slotB != null) {131 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))132 } else {133 writeMatchingNoiseSlotB(slotA)134 }135 store.stampLocalMutation()136 unlockLocked(password)137 } catch (e: Exception) {138 UnlockResult.Error(e.message ?: "restore_failed")139 }140 }141142 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {143 return try {144 val salt = VaultCrypto.generateSalt()145 val key = VaultCrypto.deriveKey(password, salt)146 entryList = mutableListOf()147 duressConfigured = false148 recoveryWordHash = null149 recoveryWordsSet = false150 decoyMode = false151 rootKey = generateRecoverableRootKey()152 val slotA = sealSlotA(key, salt)153 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")154 writeMatchingNoiseSlotB(slotA)155 store.stampLocalMutation()156 store.writeSettings(_settings.value)157 derivedKey = key158 publish()159 _isUnlocked.value = true160 CreateVaultResult.Success161 } catch (e: Exception) {162 CreateVaultResult.Error(e.message ?: "create_failed")163 }164 }165166 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {167 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")168 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {169 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")170 }171 var adoptedRootKey: ByteArray? = null172 var nextDerivedKey: ByteArray? = null173 var sessionRootKey: ByteArray? = null174 return try {175 adoptedRootKey = hexToRootKey(rootKeyHex)176 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {177 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")178 }179 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)180 val salt = VaultCrypto.generateSalt()181 nextDerivedKey = VaultCrypto.deriveKey(password, salt)182 sessionRootKey = adoptedRootKey.copyOf()183 val payload = VaultPayload(184 entries = emptyList(),185 duressConfigured = false,186 rootKeyB64 = b64(sessionRootKey),187 recoveryWordHash = null,188 recoveryWordsSet = false,189 )190 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))191 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)192 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")193 writeMatchingNoiseSlotB(slotA)194 store.stampLocalMutation()195 store.writeSettings(_settings.value)196197 val previousDerivedKey = derivedKey198 val previousRootKey = rootKey199 derivedKey = nextDerivedKey200 rootKey = sessionRootKey201 nextDerivedKey = null202 sessionRootKey = null203 entryList = mutableListOf()204 duressConfigured = false205 recoveryWordHash = null206 recoveryWordsSet = false207 decoyMode = false208 publish()209 _isUnlocked.value = true210 previousDerivedKey?.let(VaultCrypto::zero)211 previousRootKey?.let(VaultCrypto::zero)212 AdoptVaultResult.Success(vaultId)213 } catch (e: Exception) {214 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")215 } finally {216 adoptedRootKey?.let(VaultCrypto::zero)217 nextDerivedKey?.let(VaultCrypto::zero)218 sessionRootKey?.let(VaultCrypto::zero)219 }220 }221222 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }223224 225 private fun unlockLocked(password: String): UnlockResult {226 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")227 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))228 val payload = try {229 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))230 } catch (_: Exception) {231 VaultCrypto.zero(key)232 233 234 235 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {236 val legacy = tryLegacyRawUnlock(password, slotA)237 if (legacy != null) return legacy238 }239 return UnlockResult.WrongPassword240 }241 return adoptSlotA(key, payload)242 }243244 245246247248249250 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {251 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))252 val payload = try {253 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))254 } catch (_: Exception) {255 VaultCrypto.zero(rawKey)256 return null257 }258 259 260 val newSalt = VaultCrypto.generateSalt()261 val newKey = VaultCrypto.deriveKey(password, newSalt)262 val healed = try {263 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))264 val next = StoredVault(265 version = slotA.version,266 salt = b64(newSalt),267 blob = blob,268 recoveryEscrow = slotA.recoveryEscrow,269 rootKeyEscrow = slotA.rootKeyEscrow,270 )271 if (store.writeSlotA(next)) {272 writeMatchingNoiseSlotB(next)273 store.stampLocalMutation()274 true275 } else {276 false277 }278 } catch (_: Exception) {279 false280 }281 return if (healed) {282 VaultCrypto.zero(rawKey)283 adoptSlotA(newKey, payload) 284 } else {285 VaultCrypto.zero(newKey)286 adoptSlotA(rawKey, payload) 287 }288 }289290 291 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {292 return try {293 derivedKey = key294 decoyMode = false295 entryList = payload.entries.toMutableList()296 duressConfigured = payload.duressConfigured297 recoveryWordHash = payload.recoveryWordHash298 recoveryWordsSet = payload.recoveryWordsSet299 _settings.value = store.loadSettings()300 ensureRootKey(payload)301 autoPurgeExpiredTrash()302 publish()303 _isUnlocked.value = true304 UnlockResult.Success305 } catch (e: Exception) {306 UnlockResult.Error(e.message ?: "unlock_failed")307 }308 }309310 311312 313314315316317318 class SlotAUnlock internal constructor(319 internal val key: ByteArray,320 internal val payload: VaultPayload,321 )322323 324325326327328329 fun tryUnlockSlotA(password: String): SlotAUnlock? {330 val slotA = store.loadSlotA() ?: return null331 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))332 return try {333 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))334 SlotAUnlock(key, payload)335 } catch (_: Exception) {336 VaultCrypto.zero(key)337 null338 }339 }340341 342 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {343 adoptSlotA(unlock.key, unlock.payload)344 }345346 347 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)348349 350351 352353354355356 class DuressUnlock internal constructor(357 internal val key: ByteArray,358 internal val payload: VaultPayload,359 )360361 362363364365366367368 fun tryUnlockDuress(password: String): DuressUnlock? {369 val slot = store.loadDecoySlot() ?: return null370 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))371 return try {372 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))373 DuressUnlock(key, payload)374 } catch (_: Exception) {375 VaultCrypto.zero(key)376 null377 }378 }379380 381 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {382 return try {383 derivedKey = unlock.key384 decoyMode = true385 entryList = unlock.payload.entries.toMutableList()386 duressConfigured = false387 recoveryWordHash = unlock.payload.recoveryWordHash388 recoveryWordsSet = unlock.payload.recoveryWordsSet389 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)390 else VaultCrypto.generateRootKey()391 _settings.value = store.loadSettings()392 publish()393 _isUnlocked.value = true394 UnlockResult.Success395 } catch (e: Exception) {396 UnlockResult.Error(e.message ?: "decoy_unlock_failed")397 }398 }399400 401 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)402403 404405406407408409 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {410 if (derivedKey == null || decoyMode) return false411 412 413 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }414 return try {415 val salt = VaultCrypto.generateSalt()416 val key = VaultCrypto.deriveKey(duressPassphrase, salt)417 418 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))419 val plaintext = VaultCodec.encode(decoyPayload)420 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))421 VaultCrypto.zero(key)422 VaultCrypto.zero(plaintext)423 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))424 if (ok) {425 duressConfigured = true426 runCatching { persistSlotA(bumpVersion = false) }427 }428 ok429 } catch (_: Exception) {430 false431 }432 }433434 435 suspend fun clearDuress(): Boolean = mutex.withLock {436 store.clearDecoySlot()437 if (derivedKey != null && !decoyMode) {438 duressConfigured = false439 runCatching { persistSlotA(bumpVersion = false) }440 }441 true442 }443444 445 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {446 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")447 return try {448 val payload = VaultCodec.decode(449 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),450 )451 derivedKey = key.copyOf()452 decoyMode = false453 entryList = payload.entries.toMutableList()454 duressConfigured = payload.duressConfigured455 recoveryWordHash = payload.recoveryWordHash456 recoveryWordsSet = payload.recoveryWordsSet457 _settings.value = store.loadSettings()458 ensureRootKey(payload)459 autoPurgeExpiredTrash()460 publish()461 _isUnlocked.value = true462 UnlockResult.Success463 } catch (_: Exception) {464 UnlockResult.WrongPassword465 }466 }467468 fun lock() {469 derivedKey?.let(VaultCrypto::zero)470 rootKey?.let(VaultCrypto::zero)471 derivedKey = null472 rootKey = null473 entryList = mutableListOf()474 duressConfigured = false475 recoveryWordHash = null476 recoveryWordsSet = false477 decoyMode = false478 _entries.value = emptyList()479 _isUnlocked.value = false480 }481482 @Volatile private var suppressNextBackgroundLock = false483484 485486487488489490491492493 fun expectTransientBackground() {494 suppressNextBackgroundLock = true495 }496497 498 fun consumeTransientBackgroundSuppression(): Boolean {499 val suppressed = suppressNextBackgroundLock500 suppressNextBackgroundLock = false501 return suppressed502 }503504 505506 507508509510511 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()512513 514 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }515516 517518519520521 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {522 val key = derivedKey ?: return false523 if (decoyMode) return false524 val priorSlot = store.loadSlotA() ?: return false525 val priorRootKey = rootKey ?: return false526 val priorRecoveryHash = recoveryWordHash527 val priorRecoverySet = recoveryWordsSet528 val nextRootKey = generateRecoverableRootKey()529 val nextWords = rootKeyToWords(nextRootKey)530531 try {532 takeVaultSnapshot("local_save")533 rootKey = nextRootKey534 recoveryWordHash = recoveryWordsHash(nextWords)535 recoveryWordsSet = false536537 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)538 ?: throw IllegalStateException("recovery_escrow_failed")539 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)540 ?: throw IllegalStateException("root_escrow_failed")541 val plaintext = VaultCodec.encode(currentPayload())542 val blob = try {543 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))544 } finally {545 VaultCrypto.zero(plaintext)546 }547 val nextSlot = priorSlot.copy(548 blob = blob,549 recoveryEscrow = nextRecoveryEscrow,550 rootKeyEscrow = nextRootKeyEscrow,551 )552 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")553 try {554 writeMatchingNoiseSlotB(nextSlot)555 store.stampLocalMutation()556 } catch (e: Exception) {557 store.writeSlotA(priorSlot)558 throw e559 }560 VaultCrypto.zero(priorRootKey)561 publish()562 true563 } catch (_: Exception) {564 rootKey = priorRootKey565 recoveryWordHash = priorRecoveryHash566 recoveryWordsSet = priorRecoverySet567 VaultCrypto.zero(nextRootKey)568 store.writeSlotA(priorSlot)569 false570 }571 }572573 574575576577578 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()579580 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {581 val slotA = store.loadSlotA() ?: return false582 return try {583 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))584 val payload = VaultCodec.decode(585 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),586 )587 val newSalt = VaultCrypto.generateSalt()588 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)589 590 val plaintext = VaultCodec.encode(payload)591 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))592 593 594 595 596 597 598 599 val next = StoredVault(600 version = slotA.version,601 salt = b64(newSalt),602 blob = blob,603 recoveryEscrow = slotA.recoveryEscrow,604 rootKeyEscrow = slotA.rootKeyEscrow,605 )606 if (!store.writeSlotA(next)) return false607 writeMatchingNoiseSlotB(next)608 store.stampLocalMutation()609 VaultCrypto.zero(oldKey)610 derivedKey?.let(VaultCrypto::zero)611 derivedKey = newKey612 true613 } catch (_: Exception) {614 false615 }616 }617618 619620621622 suspend fun destroyVault() = mutex.withLock {623 lock()624 store.clearVault()625 store.clearDecoySlot()626 }627628 629630 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {631 requireUnlocked()632 val stamp = VaultCrypto.monotonicEntryTimestamp()633 val now = System.currentTimeMillis()634 val entry = withDerivedLast4(draft.copy(635 id = draft.id.ifBlank { UUID.randomUUID().toString() },636 createdAt = if (draft.createdAt > 0) draft.createdAt else now,637 modifiedAt = stamp,638 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,639 ))640 val rollback = entryList.toMutableList()641 entryList.add(entry)642 persistOrRollback(rollback)643 entry644 }645646 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {647 requireUnlocked()648 val idx = entryList.indexOfFirst { it.id == patch.id }649 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")650 val current = entryList[idx]651 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)652 653 val passwordChanged = patch.password != null && patch.password != current.password654 655 656 657 658 659 660 val priorPassword = current.password661 val shouldCapture = passwordChanged &&662 !priorPassword.isNullOrEmpty() &&663 current.passwordHistory?.firstOrNull()?.password != priorPassword664 val nextHistory = if (shouldCapture) {665 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +666 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)667 } else {668 current.passwordHistory669 }670 val updated = withDerivedLast4(patch.copy(671 createdAt = current.createdAt,672 modifiedAt = stamp,673 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,674 passwordHistory = nextHistory,675 ))676 val rollback = entryList.toMutableList()677 entryList[idx] = updated678 persistOrRollback(rollback)679 updated680 }681682 683 suspend fun deleteEntry(id: String) = mutex.withLock {684 requireUnlocked()685 val idx = entryList.indexOfFirst { it.id == id }686 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")687 val current = entryList[idx]688 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)689 val rollback = entryList.toMutableList()690 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)691 persistOrRollback(rollback)692 }693694 suspend fun restoreEntry(id: String) = mutex.withLock {695 requireUnlocked()696 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }697 if (idx == -1) throw IllegalStateException("deleted entry not found")698 val current = entryList[idx]699 val rollback = entryList.toMutableList()700 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))701 persistOrRollback(rollback)702 }703704 705 suspend fun purgeEntry(id: String) = mutex.withLock {706 requireUnlocked()707 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }708 if (idx == -1) throw IllegalStateException("deleted entry not found")709 val rollback = entryList.toMutableList()710 entryList.removeAt(idx)711 persistOrRollback(rollback)712 }713714 715 suspend fun purgeDeletedEntries(): Int = mutex.withLock {716 requireUnlocked()717 val rollback = entryList.toMutableList()718 val before = entryList.size719 entryList.removeAll { it.deletedAt != null }720 val removed = before - entryList.size721 if (removed > 0) persistOrRollback(rollback)722 removed723 }724725 726 suspend fun countDuplicateEntries(): Int = mutex.withLock {727 requireUnlocked()728 countDuplicateEntriesLocked()729 }730731 732733734735736 suspend fun deduplicateEntries(): Int = mutex.withLock {737 requireUnlocked()738 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")739 val active = entryList.filter { it.deletedAt == null }740 val keepIds = mutableSetOf<String>()741 active.groupBy { it.duplicateKey() }.values.forEach { group ->742 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id743 }744745 val rollback = entryList.toMutableList()746 var removed = 0747 entryList = entryList.map { entry ->748 if (entry.deletedAt == null && entry.id !in keepIds) {749 removed++750 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)751 entry.copy(deletedAt = stamp, modifiedAt = stamp)752 } else {753 entry754 }755 }.toMutableList()756 if (removed > 0) persistOrRollback(rollback)757 removed758 }759760 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }761 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }762 fun getDeletedEntries(): List<VaultEntry> =763 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }764765 766 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {767 val active = entryList.filter { it.deletedAt == null }768 if (tagFilter == null) return active769 val needle = tagFilter.trim().lowercase()770 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }771 }772773 774 fun getDistinctTags(): List<String> =775 entryList.asSequence()776 .filter { it.deletedAt == null }777 .flatMap { it.tags?.asSequence() ?: emptySequence() }778 .map { it.trim().lowercase() }779 .filter { it.isNotEmpty() }780 .distinct()781 .sorted()782 .toList()783784 785786787788789790 fun getDistinctFieldValues(field: String): List<String> {791 val select: (VaultEntry) -> String? = when (field) {792 "username" -> { e -> e.username }793 "email" -> { e -> e.email }794 else -> return emptyList()795 }796 return entryList.asSequence()797 .filter { it.deletedAt == null }798 .mapNotNull { select(it)?.trim() }799 .filter { it.isNotEmpty() }800 .groupingBy { it.lowercase() }801 .aggregate { _, acc: Pair<String, Int>?, value, _ ->802 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)803 }804 .values805 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })806 .map { it.first }807 }808809 fun getTombstones(): List<Tombstone> =810 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }811 fun getVaultVersion(): Long = store.version()812 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots813814 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {815 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false816 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false817 takeVaultSnapshot("restore")818 if (!store.writeSlotA(restoredA)) return false819 if (!store.writeSlotB(snapshot.vault_b)) return false820 store.stampLocalMutation()821 lock()822 true823 }824825 826827 828829830831832 suspend fun applyMergedRemoteEntries(833 remoteEntries: List<VaultEntry>,834 remoteTombstones: List<Tombstone>,835 ): Int = mutex.withLock {836 requireUnlocked()837 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }838 var applied = 0839 val byId = entryList.associateBy { it.id }.toMutableMap()840841 for (remote in remoteEntries) {842 val tomb = tombMap[remote.id]843 if (tomb != null && tomb >= remote.modifiedAt) continue844 val local = byId[remote.id]845 if (local == null) {846 entryList.add(remote); byId[remote.id] = remote; applied++847 } else if (remote.modifiedAt > local.modifiedAt) {848 val i = entryList.indexOfFirst { it.id == local.id }849 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }850 }851 }852853 for ((id, deletedAt) in tombMap) {854 val local = byId[id] ?: continue855 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue856 if (deletedAt < local.modifiedAt) continue857 val i = entryList.indexOfFirst { it.id == id }858 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }859 }860861 if (applied > 0) {862 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")863 publish()864 }865 applied866 }867868 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {869 _settings.value = settings870 store.writeSettings(settings)871 }872873 874875 876 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet877878 879880881882883 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {884 requireUnlocked()885 recoveryWordHash = recoveryWordsHash(words)886 recoveryWordsSet = true887 888 889 890 891 runCatching {892 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))893 }.getOrDefault(false)894 }895896 897 fun verifyRecoveryWords(words: List<String>): Boolean {898 val stored = recoveryWordHash ?: return false899 return stored == recoveryWordsHash(words)900 }901902 903904 905 fun hasRecoveryEscrow(): Boolean =906 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false907908 909910911912913914915916917918919920 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {921 val slotA = store.loadSlotA() ?: return false922 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false923924 925 926 slotA.recoveryEscrow?.let { escrow ->927 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let928 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))929 val keyBytes = recoveryKey.encoded930 try {931 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)932 val payload = VaultCodec.decode(plaintext)933 decoyMode = false934 entryList = payload.entries.toMutableList()935 duressConfigured = payload.duressConfigured936 recoveryWordHash = payload.recoveryWordHash937 recoveryWordsSet = payload.recoveryWordsSet938 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)939 else generateRecoverableRootKey()940 _settings.value = store.loadSettings()941 VaultCrypto.zero(plaintext)942 return true943 } catch (_: Exception) {944 945 } finally {946 VaultCrypto.zero(material)947 VaultCrypto.zero(keyBytes)948 }949 }950951 952 953 954 slotA.rootKeyEscrow?.let { escrow ->955 val rk = unsealRootKeyEscrow(words, escrow) ?: return false956 decoyMode = false957 entryList = mutableListOf()958 duressConfigured = false959 recoveryWordHash = recoveryWordsHash(words)960 recoveryWordsSet = true961 rootKey = rk962 _settings.value = store.loadSettings()963 return true964 }965 return false966 }967968 969970971972973974975976977 suspend fun resetPasswordWithRecoveryWords(978 words: List<String>,979 newPassword: String,980 ): Boolean = mutex.withLock {981 if (rootKey == null) return false 982 return try {983 val newSalt = VaultCrypto.generateSalt()984 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)985 val plaintext = VaultCodec.encode(currentPayload())986 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))987 val prior = store.loadSlotA()988 989 990 991 val valid = words.size == 12 && findInvalidWords(words).isEmpty()992 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow993 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow994 val next = StoredVault(995 version = prior?.version ?: 1,996 salt = b64(newSalt),997 blob = blob,998 recoveryEscrow = freshRecovery,999 rootKeyEscrow = freshRootKey,1000 )1001 if (!store.writeSlotA(next)) return false1002 writeMatchingNoiseSlotB(next)1003 store.stampLocalMutation()1004 derivedKey?.let(VaultCrypto::zero)1005 derivedKey = newKey1006 VaultCrypto.zero(plaintext)1007 publish()1008 _isUnlocked.value = true1009 true1010 } catch (_: Exception) {1011 false1012 }1013 }10141015 101610171018101910201021 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1022 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1023 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1024 val salt = VaultCrypto.generateSalt()1025 val recoveryKey = deriveRecoveryKey(material, salt)1026 val keyBytes = recoveryKey.encoded1027 val plaintext = VaultCodec.encode(currentPayload())1028 return try {1029 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1030 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1031 } finally {1032 VaultCrypto.zero(material)1033 VaultCrypto.zero(keyBytes)1034 VaultCrypto.zero(plaintext)1035 }1036 }10371038 10391040104110421043104410451046 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1047 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1048 val rk = rootKey ?: return null1049 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1050 val salt = VaultCrypto.generateSalt()1051 val recoveryKey = deriveRecoveryKey(material, salt)1052 val keyBytes = recoveryKey.encoded1053 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1054 return try {1055 1056 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1057 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1058 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1059 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1060 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1061 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1062 } finally {1063 VaultCrypto.zero(material)1064 VaultCrypto.zero(keyBytes)1065 VaultCrypto.zero(plaintext)1066 }1067 }10681069 1070 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1071 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1072 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1073 val keyBytes = recoveryKey.encoded1074 return try {1075 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1076 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1077 hexToRootKey(String(pt, Charsets.US_ASCII))1078 } catch (_: Exception) {1079 null1080 } finally {1081 VaultCrypto.zero(material)1082 VaultCrypto.zero(keyBytes)1083 }1084 }10851086 108710881089109010911092 private fun persistSlotAWithEscrows(1093 recoveryEscrow: RecoveryEscrow?,1094 rootKeyEscrow: RootKeyEscrow?,1095 ): Boolean {1096 val key = derivedKey ?: throw IllegalStateException("vault_locked")1097 if (decoyMode) return false1098 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1099 val plaintext = VaultCodec.encode(currentPayload())1100 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1101 val next = prior.copy(1102 blob = blob,1103 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1104 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1105 )1106 if (!store.writeSlotA(next)) return false1107 try {1108 writeMatchingNoiseSlotB(next)1109 } catch (e: Exception) {1110 store.writeSlotA(prior)1111 throw e1112 }1113 return true1114 }11151116 11171118 private fun requireUnlocked() {1119 if (derivedKey == null) throw IllegalStateException("vault_locked")1120 }11211122 private fun countDuplicateEntriesLocked(): Int {1123 val seen = mutableMapOf<String, VaultEntry>()1124 var duplicates = 01125 for (entry in entryList.filter { it.deletedAt == null }) {1126 val key = entry.duplicateKey()1127 val existing = seen[key]1128 if (existing == null) {1129 seen[key] = entry1130 } else {1131 duplicates++1132 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1133 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1134 if (entryStamp > existingStamp) seen[key] = entry1135 }1136 }1137 return duplicates1138 }11391140 private fun VaultEntry.duplicateKey(): String {1141 if (type == "note") return "note|${title.lowercase()}"1142 val passkeyData = passkey1143 if (passkeyData != null) {1144 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1145 }1146 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1147 }11481149 private fun takeVaultSnapshot(reason: String) {1150 runCatching {1151 val slotA = store.loadSlotA() ?: return1152 val vaultA = VaultCodec.encodeSlot(slotA)1153 val vaultB = store.loadSlotB() ?: slotA.blob1154 val history = store.loadVaultHistory()1155 val latest = history.snapshots.firstOrNull()1156 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1157 val entryCount = if (derivedKey != null && !decoyMode) {1158 bucketEntryCount(entryList.count { it.deletedAt == null })1159 } else {1160 null1161 }1162 val snapshot = VaultSnapshot(1163 id = UUID.randomUUID().toString(),1164 savedAt = System.currentTimeMillis(),1165 reason = reason,1166 vault_a = vaultA,1167 vault_b = vaultB,1168 entryCount = entryCount,1169 )1170 store.writeVaultHistory(1171 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1172 )1173 }1174 }11751176 private fun bucketEntryCount(n: Int): Int = when {1177 n <= 0 -> 01178 n <= 10 -> 101179 n <= 25 -> 251180 n <= 50 -> 501181 n <= 100 -> 1001182 n <= 250 -> 2501183 n <= 500 -> 5001184 else -> ((n + 499) / 500) * 5001185 }11861187 11881189119011911192 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1193 if (!entry.isCard()) return entry1194 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1195 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1196 }11971198 119912001201120212031204 private fun autoPurgeExpiredTrash() {1205 val days = _settings.value.trashRetentionDays ?: return1206 if (days <= 0) return1207 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1208 val before = entryList.size1209 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1210 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1211 }12121213 1214 private fun currentPayload(): VaultPayload =1215 VaultPayload(1216 entries = entryList.toList(),1217 duressConfigured = duressConfigured,1218 rootKeyB64 = rootKey?.let(::b64) ?: "",1219 recoveryWordHash = recoveryWordHash,1220 recoveryWordsSet = recoveryWordsSet,1221 )12221223 12241225122612271228 private fun ensureRootKey(payload: VaultPayload) {1229 if (payload.rootKeyB64.isNotBlank()) {1230 rootKey = unb64(payload.rootKeyB64)1231 return1232 }1233 rootKey = generateRecoverableRootKey()1234 runCatching { persistSlotA(bumpVersion = false) }1235 }12361237 private fun publish() {1238 _entries.value = entryList.filter { it.deletedAt == null }1239 }12401241 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1242 val plaintext = VaultCodec.encode(currentPayload())1243 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1244 return StoredVault(version = 1, salt = b64(salt), blob = blob)1245 }12461247 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1248 1249 1250 1251 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1252 val noise = ByteArray(approxBytes).also(rng::nextBytes)1253 store.writeSlotB(b64(noise))1254 }12551256 1257 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1258 val key = derivedKey ?: throw IllegalStateException("vault_locked")1259 1260 if (decoyMode) {1261 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1262 val plaintext = VaultCodec.encode(currentPayload())1263 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1264 return store.writeDecoySlot(prior.copy(blob = blob))1265 }1266 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1267 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1268 val plaintext = VaultCodec.encode(currentPayload())1269 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1270 val next = prior.copy(blob = blob)1271 if (!store.writeSlotA(next)) return false1272 1273 1274 try {1275 writeMatchingNoiseSlotB(next)1276 if (bumpVersion) store.stampLocalMutation()1277 } catch (e: Exception) {1278 store.writeSlotA(prior)1279 throw e1280 }1281 return true1282 }12831284 1285 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1286 try {1287 if (!persistSlotA(bumpVersion = true)) {1288 entryList = rollback1289 throw IllegalStateException("storage_write_failed")1290 }1291 } catch (e: Exception) {1292 entryList = rollback1293 throw e1294 }1295 publish()1296 }1297}