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.Singleton1920private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)21private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)222324private const val PASSWORD_HISTORY_MAX = 5025private const val VAULT_HISTORY_MAX = 52627sealed interface UnlockResult {28 data object Success : UnlockResult29 data object WrongPassword : UnlockResult30 data class Error(val reason: String) : UnlockResult31}3233sealed interface CreateVaultResult {34 data object Success : CreateVaultResult35 data class Error(val reason: String) : CreateVaultResult36}3738sealed interface AdoptVaultResult {39 data class Success(val vaultId: String) : AdoptVaultResult40 data class Error(val reason: String) : AdoptVaultResult41}424344454647484950515253545556@Singleton57private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }5859class VaultRepository @Inject constructor(60 private val store: VaultStorage,61) {62 private val mutex = Mutex()63 private val rng = SecureRandom()6465 private var derivedKey: ByteArray? = null66 private var rootKey: ByteArray? = null67 private var entryList: MutableList<VaultEntry> = mutableListOf()68 private var duressConfigured: Boolean = false69 private var recoveryWordHash: String? = null70 private var recoveryWordsSet: Boolean = false71 72 private var decoyMode: Boolean = false7374 private val _isUnlocked = MutableStateFlow(false)75 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()7677 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())78 79 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()8081 private val _settings = MutableStateFlow(VaultSettings())82 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()8384 fun hasVault(): Boolean = store.hasVault()8586 8788 8990919293 fun codexVaultBlobBase64(): String? {94 val slotA = store.loadSlotA() ?: return null95 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""96 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))97 }9899 100101102103104 105 private fun JsonElement?.asSlotJson(): String? = when {106 this == null || this is JsonNull -> null107 this is JsonPrimitive && isString -> content108 else -> toString()109 }110111 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {112 return try {113 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)114 115 116 117 118 119 120 121 val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject122 ?: return UnlockResult.Error("invalid_codex_blob")123 val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")124 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")125 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")126 127 128 val slotB = wrapper["b"].asSlotJson()129 if (slotB != null) {130 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))131 } else {132 writeMatchingNoiseSlotB(slotA)133 }134 store.stampLocalMutation()135 unlockLocked(password)136 } catch (e: Exception) {137 UnlockResult.Error(e.message ?: "restore_failed")138 }139 }140141 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {142 return try {143 val salt = VaultCrypto.generateSalt()144 val key = VaultCrypto.deriveKey(password, salt)145 entryList = mutableListOf()146 duressConfigured = false147 recoveryWordHash = null148 recoveryWordsSet = false149 decoyMode = false150 rootKey = generateRecoverableRootKey()151 val slotA = sealSlotA(key, salt)152 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")153 writeMatchingNoiseSlotB(slotA)154 store.stampLocalMutation()155 store.writeSettings(_settings.value)156 derivedKey = key157 publish()158 _isUnlocked.value = true159 CreateVaultResult.Success160 } catch (e: Exception) {161 CreateVaultResult.Error(e.message ?: "create_failed")162 }163 }164165 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {166 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")167 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {168 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")169 }170 var adoptedRootKey: ByteArray? = null171 var nextDerivedKey: ByteArray? = null172 var sessionRootKey: ByteArray? = null173 return try {174 adoptedRootKey = hexToRootKey(rootKeyHex)175 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {176 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")177 }178 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)179 val salt = VaultCrypto.generateSalt()180 nextDerivedKey = VaultCrypto.deriveKey(password, salt)181 sessionRootKey = adoptedRootKey.copyOf()182 val payload = VaultPayload(183 entries = emptyList(),184 duressConfigured = false,185 rootKeyB64 = b64(sessionRootKey),186 recoveryWordHash = null,187 recoveryWordsSet = false,188 )189 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))190 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)191 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")192 writeMatchingNoiseSlotB(slotA)193 store.stampLocalMutation()194 store.writeSettings(_settings.value)195196 val previousDerivedKey = derivedKey197 val previousRootKey = rootKey198 derivedKey = nextDerivedKey199 rootKey = sessionRootKey200 nextDerivedKey = null201 sessionRootKey = null202 entryList = mutableListOf()203 duressConfigured = false204 recoveryWordHash = null205 recoveryWordsSet = false206 decoyMode = false207 publish()208 _isUnlocked.value = true209 previousDerivedKey?.let(VaultCrypto::zero)210 previousRootKey?.let(VaultCrypto::zero)211 AdoptVaultResult.Success(vaultId)212 } catch (e: Exception) {213 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")214 } finally {215 adoptedRootKey?.let(VaultCrypto::zero)216 nextDerivedKey?.let(VaultCrypto::zero)217 sessionRootKey?.let(VaultCrypto::zero)218 }219 }220221 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }222223 224 private fun unlockLocked(password: String): UnlockResult {225 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")226 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))227 val payload = try {228 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))229 } catch (_: Exception) {230 VaultCrypto.zero(key)231 232 233 234 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {235 val legacy = tryLegacyRawUnlock(password, slotA)236 if (legacy != null) return legacy237 }238 return UnlockResult.WrongPassword239 }240 return adoptSlotA(key, payload)241 }242243 244245246247248249 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {250 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))251 val payload = try {252 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))253 } catch (_: Exception) {254 VaultCrypto.zero(rawKey)255 return null256 }257 258 259 val newSalt = VaultCrypto.generateSalt()260 val newKey = VaultCrypto.deriveKey(password, newSalt)261 val healed = try {262 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))263 val next = StoredVault(264 version = slotA.version,265 salt = b64(newSalt),266 blob = blob,267 recoveryEscrow = slotA.recoveryEscrow,268 rootKeyEscrow = slotA.rootKeyEscrow,269 )270 if (store.writeSlotA(next)) {271 writeMatchingNoiseSlotB(next)272 store.stampLocalMutation()273 true274 } else {275 false276 }277 } catch (_: Exception) {278 false279 }280 return if (healed) {281 VaultCrypto.zero(rawKey)282 adoptSlotA(newKey, payload) 283 } else {284 VaultCrypto.zero(newKey)285 adoptSlotA(rawKey, payload) 286 }287 }288289 290 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {291 return try {292 derivedKey = key293 decoyMode = false294 entryList = payload.entries.toMutableList()295 duressConfigured = payload.duressConfigured296 recoveryWordHash = payload.recoveryWordHash297 recoveryWordsSet = payload.recoveryWordsSet298 _settings.value = store.loadSettings()299 ensureRootKey(payload)300 autoPurgeExpiredTrash()301 publish()302 _isUnlocked.value = true303 UnlockResult.Success304 } catch (e: Exception) {305 UnlockResult.Error(e.message ?: "unlock_failed")306 }307 }308309 310311 312313314315316317 class SlotAUnlock internal constructor(318 internal val key: ByteArray,319 internal val payload: VaultPayload,320 )321322 323324325326327328 fun tryUnlockSlotA(password: String): SlotAUnlock? {329 val slotA = store.loadSlotA() ?: return null330 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))331 return try {332 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))333 SlotAUnlock(key, payload)334 } catch (_: Exception) {335 VaultCrypto.zero(key)336 null337 }338 }339340 341 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {342 adoptSlotA(unlock.key, unlock.payload)343 }344345 346 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)347348 349350 351352353354355 class DuressUnlock internal constructor(356 internal val key: ByteArray,357 internal val payload: VaultPayload,358 )359360 361362363364365366367 fun tryUnlockDuress(password: String): DuressUnlock? {368 val slot = store.loadDecoySlot() ?: return null369 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))370 return try {371 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))372 DuressUnlock(key, payload)373 } catch (_: Exception) {374 VaultCrypto.zero(key)375 null376 }377 }378379 380 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {381 return try {382 derivedKey = unlock.key383 decoyMode = true384 entryList = unlock.payload.entries.toMutableList()385 duressConfigured = false386 recoveryWordHash = unlock.payload.recoveryWordHash387 recoveryWordsSet = unlock.payload.recoveryWordsSet388 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)389 else VaultCrypto.generateRootKey()390 _settings.value = store.loadSettings()391 publish()392 _isUnlocked.value = true393 UnlockResult.Success394 } catch (e: Exception) {395 UnlockResult.Error(e.message ?: "decoy_unlock_failed")396 }397 }398399 400 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)401402 403404405406407408 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {409 if (derivedKey == null || decoyMode) return false410 411 412 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }413 return try {414 val salt = VaultCrypto.generateSalt()415 val key = VaultCrypto.deriveKey(duressPassphrase, salt)416 417 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))418 val plaintext = VaultCodec.encode(decoyPayload)419 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))420 VaultCrypto.zero(key)421 VaultCrypto.zero(plaintext)422 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))423 if (ok) {424 duressConfigured = true425 runCatching { persistSlotA(bumpVersion = false) }426 }427 ok428 } catch (_: Exception) {429 false430 }431 }432433 434 suspend fun clearDuress(): Boolean = mutex.withLock {435 store.clearDecoySlot()436 if (derivedKey != null && !decoyMode) {437 duressConfigured = false438 runCatching { persistSlotA(bumpVersion = false) }439 }440 true441 }442443 444 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {445 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")446 return try {447 val payload = VaultCodec.decode(448 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),449 )450 derivedKey = key.copyOf()451 decoyMode = false452 entryList = payload.entries.toMutableList()453 duressConfigured = payload.duressConfigured454 recoveryWordHash = payload.recoveryWordHash455 recoveryWordsSet = payload.recoveryWordsSet456 _settings.value = store.loadSettings()457 ensureRootKey(payload)458 autoPurgeExpiredTrash()459 publish()460 _isUnlocked.value = true461 UnlockResult.Success462 } catch (_: Exception) {463 UnlockResult.WrongPassword464 }465 }466467 fun lock() {468 derivedKey?.let(VaultCrypto::zero)469 rootKey?.let(VaultCrypto::zero)470 derivedKey = null471 rootKey = null472 entryList = mutableListOf()473 duressConfigured = false474 recoveryWordHash = null475 recoveryWordsSet = false476 decoyMode = false477 _entries.value = emptyList()478 _isUnlocked.value = false479 }480481 @Volatile private var suppressNextBackgroundLock = false482483 484485486487488489490491492 fun expectTransientBackground() {493 suppressNextBackgroundLock = true494 }495496 497 fun consumeTransientBackgroundSuppression(): Boolean {498 val suppressed = suppressNextBackgroundLock499 suppressNextBackgroundLock = false500 return suppressed501 }502503 504505 506507508509510 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()511512 513 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }514515 516517518519520 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {521 val key = derivedKey ?: return false522 if (decoyMode) return false523 val priorSlot = store.loadSlotA() ?: return false524 val priorRootKey = rootKey ?: return false525 val priorRecoveryHash = recoveryWordHash526 val priorRecoverySet = recoveryWordsSet527 val nextRootKey = generateRecoverableRootKey()528 val nextWords = rootKeyToWords(nextRootKey)529530 try {531 takeVaultSnapshot("local_save")532 rootKey = nextRootKey533 recoveryWordHash = recoveryWordsHash(nextWords)534 recoveryWordsSet = false535536 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)537 ?: throw IllegalStateException("recovery_escrow_failed")538 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)539 ?: throw IllegalStateException("root_escrow_failed")540 val plaintext = VaultCodec.encode(currentPayload())541 val blob = try {542 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))543 } finally {544 VaultCrypto.zero(plaintext)545 }546 val nextSlot = priorSlot.copy(547 blob = blob,548 recoveryEscrow = nextRecoveryEscrow,549 rootKeyEscrow = nextRootKeyEscrow,550 )551 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")552 try {553 writeMatchingNoiseSlotB(nextSlot)554 store.stampLocalMutation()555 } catch (e: Exception) {556 store.writeSlotA(priorSlot)557 throw e558 }559 VaultCrypto.zero(priorRootKey)560 publish()561 true562 } catch (_: Exception) {563 rootKey = priorRootKey564 recoveryWordHash = priorRecoveryHash565 recoveryWordsSet = priorRecoverySet566 VaultCrypto.zero(nextRootKey)567 store.writeSlotA(priorSlot)568 false569 }570 }571572 573574575576577 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()578579 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {580 val slotA = store.loadSlotA() ?: return false581 return try {582 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))583 val payload = VaultCodec.decode(584 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),585 )586 val newSalt = VaultCrypto.generateSalt()587 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)588 589 val plaintext = VaultCodec.encode(payload)590 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))591 592 593 594 595 596 597 598 val next = StoredVault(599 version = slotA.version,600 salt = b64(newSalt),601 blob = blob,602 recoveryEscrow = slotA.recoveryEscrow,603 rootKeyEscrow = slotA.rootKeyEscrow,604 )605 if (!store.writeSlotA(next)) return false606 writeMatchingNoiseSlotB(next)607 store.stampLocalMutation()608 VaultCrypto.zero(oldKey)609 derivedKey?.let(VaultCrypto::zero)610 derivedKey = newKey611 true612 } catch (_: Exception) {613 false614 }615 }616617 618619620621 suspend fun destroyVault() = mutex.withLock {622 lock()623 store.clearVault()624 store.clearDecoySlot()625 }626627 628629 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {630 requireUnlocked()631 val stamp = VaultCrypto.monotonicEntryTimestamp()632 val now = System.currentTimeMillis()633 val entry = withDerivedLast4(draft.copy(634 id = draft.id.ifBlank { UUID.randomUUID().toString() },635 createdAt = if (draft.createdAt > 0) draft.createdAt else now,636 modifiedAt = stamp,637 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,638 ))639 val rollback = entryList.toMutableList()640 entryList.add(entry)641 persistOrRollback(rollback)642 entry643 }644645 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {646 requireUnlocked()647 val idx = entryList.indexOfFirst { it.id == patch.id }648 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")649 val current = entryList[idx]650 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)651 652 val passwordChanged = patch.password != null && patch.password != current.password653 654 655 656 657 658 659 val priorPassword = current.password660 val shouldCapture = passwordChanged &&661 !priorPassword.isNullOrEmpty() &&662 current.passwordHistory?.firstOrNull()?.password != priorPassword663 val nextHistory = if (shouldCapture) {664 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +665 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)666 } else {667 current.passwordHistory668 }669 val updated = withDerivedLast4(patch.copy(670 createdAt = current.createdAt,671 modifiedAt = stamp,672 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,673 passwordHistory = nextHistory,674 ))675 val rollback = entryList.toMutableList()676 entryList[idx] = updated677 persistOrRollback(rollback)678 updated679 }680681 682 suspend fun deleteEntry(id: String) = mutex.withLock {683 requireUnlocked()684 val idx = entryList.indexOfFirst { it.id == id }685 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")686 val current = entryList[idx]687 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)688 val rollback = entryList.toMutableList()689 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)690 persistOrRollback(rollback)691 }692693 suspend fun restoreEntry(id: String) = mutex.withLock {694 requireUnlocked()695 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }696 if (idx == -1) throw IllegalStateException("deleted entry not found")697 val current = entryList[idx]698 val rollback = entryList.toMutableList()699 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))700 persistOrRollback(rollback)701 }702703 704 suspend fun purgeEntry(id: String) = mutex.withLock {705 requireUnlocked()706 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }707 if (idx == -1) throw IllegalStateException("deleted entry not found")708 val rollback = entryList.toMutableList()709 entryList.removeAt(idx)710 persistOrRollback(rollback)711 }712713 714 suspend fun purgeDeletedEntries(): Int = mutex.withLock {715 requireUnlocked()716 val rollback = entryList.toMutableList()717 val before = entryList.size718 entryList.removeAll { it.deletedAt != null }719 val removed = before - entryList.size720 if (removed > 0) persistOrRollback(rollback)721 removed722 }723724 725 suspend fun countDuplicateEntries(): Int = mutex.withLock {726 requireUnlocked()727 countDuplicateEntriesLocked()728 }729730 731732733734735 suspend fun deduplicateEntries(): Int = mutex.withLock {736 requireUnlocked()737 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")738 val active = entryList.filter { it.deletedAt == null }739 val keepIds = mutableSetOf<String>()740 active.groupBy { it.duplicateKey() }.values.forEach { group ->741 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id742 }743744 val rollback = entryList.toMutableList()745 var removed = 0746 entryList = entryList.map { entry ->747 if (entry.deletedAt == null && entry.id !in keepIds) {748 removed++749 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)750 entry.copy(deletedAt = stamp, modifiedAt = stamp)751 } else {752 entry753 }754 }.toMutableList()755 if (removed > 0) persistOrRollback(rollback)756 removed757 }758759 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }760 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }761 fun getDeletedEntries(): List<VaultEntry> =762 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }763764 765 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {766 val active = entryList.filter { it.deletedAt == null }767 if (tagFilter == null) return active768 val needle = tagFilter.trim().lowercase()769 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }770 }771772 773 fun getDistinctTags(): List<String> =774 entryList.asSequence()775 .filter { it.deletedAt == null }776 .flatMap { it.tags?.asSequence() ?: emptySequence() }777 .map { it.trim().lowercase() }778 .filter { it.isNotEmpty() }779 .distinct()780 .sorted()781 .toList()782783 784785786787788789 fun getDistinctFieldValues(field: String): List<String> {790 val select: (VaultEntry) -> String? = when (field) {791 "username" -> { e -> e.username }792 "email" -> { e -> e.email }793 else -> return emptyList()794 }795 return entryList.asSequence()796 .filter { it.deletedAt == null }797 .mapNotNull { select(it)?.trim() }798 .filter { it.isNotEmpty() }799 .groupingBy { it.lowercase() }800 .aggregate { _, acc: Pair<String, Int>?, value, _ ->801 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)802 }803 .values804 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })805 .map { it.first }806 }807808 fun getTombstones(): List<Tombstone> =809 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }810 fun getVaultVersion(): Long = store.version()811 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots812813 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {814 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false815 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false816 takeVaultSnapshot("restore")817 if (!store.writeSlotA(restoredA)) return false818 if (!store.writeSlotB(snapshot.vault_b)) return false819 store.stampLocalMutation()820 lock()821 true822 }823824 825826 827828829830831 suspend fun applyMergedRemoteEntries(832 remoteEntries: List<VaultEntry>,833 remoteTombstones: List<Tombstone>,834 ): Int = mutex.withLock {835 requireUnlocked()836 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }837 var applied = 0838 val byId = entryList.associateBy { it.id }.toMutableMap()839840 for (remote in remoteEntries) {841 val tomb = tombMap[remote.id]842 if (tomb != null && tomb >= remote.modifiedAt) continue843 val local = byId[remote.id]844 if (local == null) {845 entryList.add(remote); byId[remote.id] = remote; applied++846 } else if (remote.modifiedAt > local.modifiedAt) {847 val i = entryList.indexOfFirst { it.id == local.id }848 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }849 }850 }851852 for ((id, deletedAt) in tombMap) {853 val local = byId[id] ?: continue854 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue855 if (deletedAt < local.modifiedAt) continue856 val i = entryList.indexOfFirst { it.id == id }857 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }858 }859860 if (applied > 0) {861 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")862 publish()863 }864 applied865 }866867 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {868 _settings.value = settings869 store.writeSettings(settings)870 }871872 873874 875 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet876877 878879880881882 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {883 requireUnlocked()884 recoveryWordHash = recoveryWordsHash(words)885 recoveryWordsSet = true886 887 888 889 890 runCatching {891 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))892 }.getOrDefault(false)893 }894895 896 fun verifyRecoveryWords(words: List<String>): Boolean {897 val stored = recoveryWordHash ?: return false898 return stored == recoveryWordsHash(words)899 }900901 902903 904 fun hasRecoveryEscrow(): Boolean =905 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false906907 908909910911912913914915916917918919 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {920 val slotA = store.loadSlotA() ?: return false921 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false922923 924 925 slotA.recoveryEscrow?.let { escrow ->926 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let927 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))928 val keyBytes = recoveryKey.encoded929 try {930 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)931 val payload = VaultCodec.decode(plaintext)932 decoyMode = false933 entryList = payload.entries.toMutableList()934 duressConfigured = payload.duressConfigured935 recoveryWordHash = payload.recoveryWordHash936 recoveryWordsSet = payload.recoveryWordsSet937 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)938 else generateRecoverableRootKey()939 _settings.value = store.loadSettings()940 VaultCrypto.zero(plaintext)941 return true942 } catch (_: Exception) {943 944 } finally {945 VaultCrypto.zero(material)946 VaultCrypto.zero(keyBytes)947 }948 }949950 951 952 953 slotA.rootKeyEscrow?.let { escrow ->954 val rk = unsealRootKeyEscrow(words, escrow) ?: return false955 decoyMode = false956 entryList = mutableListOf()957 duressConfigured = false958 recoveryWordHash = recoveryWordsHash(words)959 recoveryWordsSet = true960 rootKey = rk961 _settings.value = store.loadSettings()962 return true963 }964 return false965 }966967 968969970971972973974975976 suspend fun resetPasswordWithRecoveryWords(977 words: List<String>,978 newPassword: String,979 ): Boolean = mutex.withLock {980 if (rootKey == null) return false 981 return try {982 val newSalt = VaultCrypto.generateSalt()983 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)984 val plaintext = VaultCodec.encode(currentPayload())985 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))986 val prior = store.loadSlotA()987 988 989 990 val valid = words.size == 12 && findInvalidWords(words).isEmpty()991 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow992 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow993 val next = StoredVault(994 version = prior?.version ?: 1,995 salt = b64(newSalt),996 blob = blob,997 recoveryEscrow = freshRecovery,998 rootKeyEscrow = freshRootKey,999 )1000 if (!store.writeSlotA(next)) return false1001 writeMatchingNoiseSlotB(next)1002 store.stampLocalMutation()1003 derivedKey?.let(VaultCrypto::zero)1004 derivedKey = newKey1005 VaultCrypto.zero(plaintext)1006 publish()1007 _isUnlocked.value = true1008 true1009 } catch (_: Exception) {1010 false1011 }1012 }10131014 101510161017101810191020 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1021 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1022 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1023 val salt = VaultCrypto.generateSalt()1024 val recoveryKey = deriveRecoveryKey(material, salt)1025 val keyBytes = recoveryKey.encoded1026 val plaintext = VaultCodec.encode(currentPayload())1027 return try {1028 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1029 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1030 } finally {1031 VaultCrypto.zero(material)1032 VaultCrypto.zero(keyBytes)1033 VaultCrypto.zero(plaintext)1034 }1035 }10361037 10381039104010411042104310441045 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1046 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1047 val rk = rootKey ?: return null1048 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1049 val salt = VaultCrypto.generateSalt()1050 val recoveryKey = deriveRecoveryKey(material, salt)1051 val keyBytes = recoveryKey.encoded1052 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1053 return try {1054 1055 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1056 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1057 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1058 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1059 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1060 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1061 } finally {1062 VaultCrypto.zero(material)1063 VaultCrypto.zero(keyBytes)1064 VaultCrypto.zero(plaintext)1065 }1066 }10671068 1069 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1070 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1071 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1072 val keyBytes = recoveryKey.encoded1073 return try {1074 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1075 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1076 hexToRootKey(String(pt, Charsets.US_ASCII))1077 } catch (_: Exception) {1078 null1079 } finally {1080 VaultCrypto.zero(material)1081 VaultCrypto.zero(keyBytes)1082 }1083 }10841085 108610871088108910901091 private fun persistSlotAWithEscrows(1092 recoveryEscrow: RecoveryEscrow?,1093 rootKeyEscrow: RootKeyEscrow?,1094 ): Boolean {1095 val key = derivedKey ?: throw IllegalStateException("vault_locked")1096 if (decoyMode) return false1097 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1098 val plaintext = VaultCodec.encode(currentPayload())1099 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1100 val next = prior.copy(1101 blob = blob,1102 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1103 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1104 )1105 if (!store.writeSlotA(next)) return false1106 try {1107 writeMatchingNoiseSlotB(next)1108 } catch (e: Exception) {1109 store.writeSlotA(prior)1110 throw e1111 }1112 return true1113 }11141115 11161117 private fun requireUnlocked() {1118 if (derivedKey == null) throw IllegalStateException("vault_locked")1119 }11201121 private fun countDuplicateEntriesLocked(): Int {1122 val seen = mutableMapOf<String, VaultEntry>()1123 var duplicates = 01124 for (entry in entryList.filter { it.deletedAt == null }) {1125 val key = entry.duplicateKey()1126 val existing = seen[key]1127 if (existing == null) {1128 seen[key] = entry1129 } else {1130 duplicates++1131 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1132 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1133 if (entryStamp > existingStamp) seen[key] = entry1134 }1135 }1136 return duplicates1137 }11381139 private fun VaultEntry.duplicateKey(): String {1140 if (type == "note") return "note|${title.lowercase()}"1141 val passkeyData = passkey1142 if (passkeyData != null) {1143 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1144 }1145 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1146 }11471148 private fun takeVaultSnapshot(reason: String) {1149 runCatching {1150 val slotA = store.loadSlotA() ?: return1151 val vaultA = VaultCodec.encodeSlot(slotA)1152 val vaultB = store.loadSlotB() ?: slotA.blob1153 val history = store.loadVaultHistory()1154 val latest = history.snapshots.firstOrNull()1155 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1156 val entryCount = if (derivedKey != null && !decoyMode) {1157 bucketEntryCount(entryList.count { it.deletedAt == null })1158 } else {1159 null1160 }1161 val snapshot = VaultSnapshot(1162 id = UUID.randomUUID().toString(),1163 savedAt = System.currentTimeMillis(),1164 reason = reason,1165 vault_a = vaultA,1166 vault_b = vaultB,1167 entryCount = entryCount,1168 )1169 store.writeVaultHistory(1170 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1171 )1172 }1173 }11741175 private fun bucketEntryCount(n: Int): Int = when {1176 n <= 0 -> 01177 n <= 10 -> 101178 n <= 25 -> 251179 n <= 50 -> 501180 n <= 100 -> 1001181 n <= 250 -> 2501182 n <= 500 -> 5001183 else -> ((n + 499) / 500) * 5001184 }11851186 11871188118911901191 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1192 if (!entry.isCard()) return entry1193 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1194 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1195 }11961197 119811991200120112021203 private fun autoPurgeExpiredTrash() {1204 val days = _settings.value.trashRetentionDays ?: return1205 if (days <= 0) return1206 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1207 val before = entryList.size1208 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1209 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1210 }12111212 1213 private fun currentPayload(): VaultPayload =1214 VaultPayload(1215 entries = entryList.toList(),1216 duressConfigured = duressConfigured,1217 rootKeyB64 = rootKey?.let(::b64) ?: "",1218 recoveryWordHash = recoveryWordHash,1219 recoveryWordsSet = recoveryWordsSet,1220 )12211222 12231224122512261227 private fun ensureRootKey(payload: VaultPayload) {1228 if (payload.rootKeyB64.isNotBlank()) {1229 rootKey = unb64(payload.rootKeyB64)1230 return1231 }1232 rootKey = generateRecoverableRootKey()1233 runCatching { persistSlotA(bumpVersion = false) }1234 }12351236 private fun publish() {1237 _entries.value = entryList.filter { it.deletedAt == null }1238 }12391240 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1241 val plaintext = VaultCodec.encode(currentPayload())1242 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1243 return StoredVault(version = 1, salt = b64(salt), blob = blob)1244 }12451246 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1247 1248 1249 1250 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1251 val noise = ByteArray(approxBytes).also(rng::nextBytes)1252 store.writeSlotB(b64(noise))1253 }12541255 1256 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1257 val key = derivedKey ?: throw IllegalStateException("vault_locked")1258 1259 if (decoyMode) {1260 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1261 val plaintext = VaultCodec.encode(currentPayload())1262 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1263 return store.writeDecoySlot(prior.copy(blob = blob))1264 }1265 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1266 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1267 val plaintext = VaultCodec.encode(currentPayload())1268 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1269 val next = prior.copy(blob = blob)1270 if (!store.writeSlotA(next)) return false1271 1272 1273 try {1274 writeMatchingNoiseSlotB(next)1275 if (bumpVersion) store.stampLocalMutation()1276 } catch (e: Exception) {1277 store.writeSlotA(prior)1278 throw e1279 }1280 return true1281 }12821283 1284 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1285 try {1286 if (!persistSlotA(bumpVersion = true)) {1287 entryList = rollback1288 throw IllegalStateException("storage_write_failed")1289 }1290 } catch (e: Exception) {1291 entryList = rollback1292 throw e1293 }1294 publish()1295 }1296}