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 126 127 128 129 130 val slotA = VaultCodec.decodeSlot(slotJson)131 ?: BrowserSlot.parse(slotJson)?.let {132 BrowserSlot.toAndroidSlot(it, password) ?: return UnlockResult.WrongPassword133 }134 ?: return UnlockResult.Error("invalid_codex_blob")135 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")136 137 138 val slotB = wrapper["b"].asSlotJson()139 if (slotB != null) {140 store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))141 } else {142 writeMatchingNoiseSlotB(slotA)143 }144 store.stampLocalMutation()145 unlockLocked(password)146 } catch (e: Exception) {147 UnlockResult.Error(e.message ?: "restore_failed")148 }149 }150151 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {152 return try {153 val salt = VaultCrypto.generateSalt()154 val key = VaultCrypto.deriveKey(password, salt)155 entryList = mutableListOf()156 duressConfigured = false157 recoveryWordHash = null158 recoveryWordsSet = false159 decoyMode = false160 rootKey = generateRecoverableRootKey()161 val slotA = sealSlotA(key, salt)162 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")163 writeMatchingNoiseSlotB(slotA)164 store.stampLocalMutation()165 store.writeSettings(_settings.value)166 derivedKey = key167 publish()168 _isUnlocked.value = true169 CreateVaultResult.Success170 } catch (e: Exception) {171 CreateVaultResult.Error(e.message ?: "create_failed")172 }173 }174175 suspend fun createVaultFromRootKey(password: String, rootKeyHex: String): AdoptVaultResult = mutex.withLock {176 if (password.isEmpty()) return AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD")177 if (!rootKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {178 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 64 lowercase hex chars")179 }180 var adoptedRootKey: ByteArray? = null181 var nextDerivedKey: ByteArray? = null182 var sessionRootKey: ByteArray? = null183 return try {184 adoptedRootKey = hexToRootKey(rootKeyHex)185 if (adoptedRootKey.size != VaultCrypto.KEY_SIZE) {186 return AdoptVaultResult.Error("ADOPT_INVALID_ROOT_KEY: expected 32 bytes")187 }188 val vaultId = com.peachpasswords.sync.computeVaultId(adoptedRootKey)189 val salt = VaultCrypto.generateSalt()190 nextDerivedKey = VaultCrypto.deriveKey(password, salt)191 sessionRootKey = adoptedRootKey.copyOf()192 val payload = VaultPayload(193 entries = emptyList(),194 duressConfigured = false,195 rootKeyB64 = b64(sessionRootKey),196 recoveryWordHash = null,197 recoveryWordsSet = false,198 )199 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), nextDerivedKey, VaultCrypto.AAD_SLOT_A))200 val slotA = StoredVault(version = 1, salt = b64(salt), blob = blob)201 if (!store.writeSlotA(slotA)) return AdoptVaultResult.Error("storage_write_failed")202 writeMatchingNoiseSlotB(slotA)203 store.stampLocalMutation()204 store.writeSettings(_settings.value)205206 val previousDerivedKey = derivedKey207 val previousRootKey = rootKey208 derivedKey = nextDerivedKey209 rootKey = sessionRootKey210 nextDerivedKey = null211 sessionRootKey = null212 entryList = mutableListOf()213 duressConfigured = false214 recoveryWordHash = null215 recoveryWordsSet = false216 decoyMode = false217 publish()218 _isUnlocked.value = true219 previousDerivedKey?.let(VaultCrypto::zero)220 previousRootKey?.let(VaultCrypto::zero)221 AdoptVaultResult.Success(vaultId)222 } catch (e: Exception) {223 AdoptVaultResult.Error(e.message ?: "adoption_create_failed")224 } finally {225 adoptedRootKey?.let(VaultCrypto::zero)226 nextDerivedKey?.let(VaultCrypto::zero)227 sessionRootKey?.let(VaultCrypto::zero)228 }229 }230231 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }232233 234 private fun unlockLocked(password: String): UnlockResult {235 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")236 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))237 val payload = try {238 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))239 } catch (_: Exception) {240 VaultCrypto.zero(key)241 242 243 244 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {245 val legacy = tryLegacyRawUnlock(password, slotA)246 if (legacy != null) return legacy247 }248 return UnlockResult.WrongPassword249 }250 return adoptSlotA(key, payload)251 }252253 254255256257258259 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {260 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))261 val payload = try {262 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))263 } catch (_: Exception) {264 VaultCrypto.zero(rawKey)265 return null266 }267 268 269 val newSalt = VaultCrypto.generateSalt()270 val newKey = VaultCrypto.deriveKey(password, newSalt)271 val healed = try {272 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))273 val next = StoredVault(274 version = slotA.version,275 salt = b64(newSalt),276 blob = blob,277 recoveryEscrow = slotA.recoveryEscrow,278 rootKeyEscrow = slotA.rootKeyEscrow,279 )280 if (store.writeSlotA(next)) {281 writeMatchingNoiseSlotB(next)282 store.stampLocalMutation()283 true284 } else {285 false286 }287 } catch (_: Exception) {288 false289 }290 return if (healed) {291 VaultCrypto.zero(rawKey)292 adoptSlotA(newKey, payload) 293 } else {294 VaultCrypto.zero(newKey)295 adoptSlotA(rawKey, payload) 296 }297 }298299 300 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {301 return try {302 derivedKey = key303 decoyMode = false304 entryList = payload.entries.toMutableList()305 duressConfigured = payload.duressConfigured306 recoveryWordHash = payload.recoveryWordHash307 recoveryWordsSet = payload.recoveryWordsSet308 _settings.value = store.loadSettings()309 ensureRootKey(payload)310 autoPurgeExpiredTrash()311 publish()312 _isUnlocked.value = true313 UnlockResult.Success314 } catch (e: Exception) {315 UnlockResult.Error(e.message ?: "unlock_failed")316 }317 }318319 320321 322323324325326327 class SlotAUnlock internal constructor(328 internal val key: ByteArray,329 internal val payload: VaultPayload,330 )331332 333334335336337338 fun tryUnlockSlotA(password: String): SlotAUnlock? {339 val slotA = store.loadSlotA() ?: return null340 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))341 return try {342 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))343 SlotAUnlock(key, payload)344 } catch (_: Exception) {345 VaultCrypto.zero(key)346 null347 }348 }349350 351 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {352 adoptSlotA(unlock.key, unlock.payload)353 }354355 356 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)357358 359360 361362363364365 class DuressUnlock internal constructor(366 internal val key: ByteArray,367 internal val payload: VaultPayload,368 )369370 371372373374375376377 fun tryUnlockDuress(password: String): DuressUnlock? {378 val slot = store.loadDecoySlot() ?: return null379 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))380 return try {381 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))382 DuressUnlock(key, payload)383 } catch (_: Exception) {384 VaultCrypto.zero(key)385 null386 }387 }388389 390 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {391 return try {392 derivedKey = unlock.key393 decoyMode = true394 entryList = unlock.payload.entries.toMutableList()395 duressConfigured = false396 recoveryWordHash = unlock.payload.recoveryWordHash397 recoveryWordsSet = unlock.payload.recoveryWordsSet398 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)399 else VaultCrypto.generateRootKey()400 _settings.value = store.loadSettings()401 publish()402 _isUnlocked.value = true403 UnlockResult.Success404 } catch (e: Exception) {405 UnlockResult.Error(e.message ?: "decoy_unlock_failed")406 }407 }408409 410 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)411412 413414415416417418 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {419 if (derivedKey == null || decoyMode) return false420 421 422 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }423 return try {424 val salt = VaultCrypto.generateSalt()425 val key = VaultCrypto.deriveKey(duressPassphrase, salt)426 427 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))428 val plaintext = VaultCodec.encode(decoyPayload)429 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))430 VaultCrypto.zero(key)431 VaultCrypto.zero(plaintext)432 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))433 if (ok) {434 duressConfigured = true435 runCatching { persistSlotA(bumpVersion = false) }436 }437 ok438 } catch (_: Exception) {439 false440 }441 }442443 444 suspend fun clearDuress(): Boolean = mutex.withLock {445 store.clearDecoySlot()446 if (derivedKey != null && !decoyMode) {447 duressConfigured = false448 runCatching { persistSlotA(bumpVersion = false) }449 }450 true451 }452453 454 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {455 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")456 return try {457 val payload = VaultCodec.decode(458 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),459 )460 derivedKey = key.copyOf()461 decoyMode = false462 entryList = payload.entries.toMutableList()463 duressConfigured = payload.duressConfigured464 recoveryWordHash = payload.recoveryWordHash465 recoveryWordsSet = payload.recoveryWordsSet466 _settings.value = store.loadSettings()467 ensureRootKey(payload)468 autoPurgeExpiredTrash()469 publish()470 _isUnlocked.value = true471 UnlockResult.Success472 } catch (_: Exception) {473 UnlockResult.WrongPassword474 }475 }476477 fun lock() {478 derivedKey?.let(VaultCrypto::zero)479 rootKey?.let(VaultCrypto::zero)480 derivedKey = null481 rootKey = null482 entryList = mutableListOf()483 duressConfigured = false484 recoveryWordHash = null485 recoveryWordsSet = false486 decoyMode = false487 _entries.value = emptyList()488 _isUnlocked.value = false489 }490491 @Volatile private var suppressNextBackgroundLock = false492493 494495496497498499500501502 fun expectTransientBackground() {503 suppressNextBackgroundLock = true504 }505506 507 fun consumeTransientBackgroundSuppression(): Boolean {508 val suppressed = suppressNextBackgroundLock509 suppressNextBackgroundLock = false510 return suppressed511 }512513 514515 516517518519520 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()521522 523 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }524525 526527528529530 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {531 val key = derivedKey ?: return false532 if (decoyMode) return false533 val priorSlot = store.loadSlotA() ?: return false534 val priorRootKey = rootKey ?: return false535 val priorRecoveryHash = recoveryWordHash536 val priorRecoverySet = recoveryWordsSet537 val nextRootKey = generateRecoverableRootKey()538 val nextWords = rootKeyToWords(nextRootKey)539540 try {541 takeVaultSnapshot("local_save")542 rootKey = nextRootKey543 recoveryWordHash = recoveryWordsHash(nextWords)544 recoveryWordsSet = false545546 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)547 ?: throw IllegalStateException("recovery_escrow_failed")548 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)549 ?: throw IllegalStateException("root_escrow_failed")550 val plaintext = VaultCodec.encode(currentPayload())551 val blob = try {552 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))553 } finally {554 VaultCrypto.zero(plaintext)555 }556 val nextSlot = priorSlot.copy(557 blob = blob,558 recoveryEscrow = nextRecoveryEscrow,559 rootKeyEscrow = nextRootKeyEscrow,560 )561 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")562 try {563 writeMatchingNoiseSlotB(nextSlot)564 store.stampLocalMutation()565 } catch (e: Exception) {566 store.writeSlotA(priorSlot)567 throw e568 }569 VaultCrypto.zero(priorRootKey)570 publish()571 true572 } catch (_: Exception) {573 rootKey = priorRootKey574 recoveryWordHash = priorRecoveryHash575 recoveryWordsSet = priorRecoverySet576 VaultCrypto.zero(nextRootKey)577 store.writeSlotA(priorSlot)578 false579 }580 }581582 583584585586587 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()588589 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {590 val slotA = store.loadSlotA() ?: return false591 return try {592 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))593 val payload = VaultCodec.decode(594 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),595 )596 val newSalt = VaultCrypto.generateSalt()597 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)598 599 val plaintext = VaultCodec.encode(payload)600 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))601 602 603 604 605 606 607 608 val next = StoredVault(609 version = slotA.version,610 salt = b64(newSalt),611 blob = blob,612 recoveryEscrow = slotA.recoveryEscrow,613 rootKeyEscrow = slotA.rootKeyEscrow,614 )615 if (!store.writeSlotA(next)) return false616 writeMatchingNoiseSlotB(next)617 store.stampLocalMutation()618 VaultCrypto.zero(oldKey)619 derivedKey?.let(VaultCrypto::zero)620 derivedKey = newKey621 true622 } catch (_: Exception) {623 false624 }625 }626627 628629630631 suspend fun destroyVault() = mutex.withLock {632 lock()633 store.clearVault()634 store.clearDecoySlot()635 }636637 638639 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {640 requireUnlocked()641 val stamp = VaultCrypto.monotonicEntryTimestamp()642 val now = System.currentTimeMillis()643 val entry = withDerivedLast4(draft.copy(644 id = draft.id.ifBlank { UUID.randomUUID().toString() },645 createdAt = if (draft.createdAt > 0) draft.createdAt else now,646 modifiedAt = stamp,647 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,648 ))649 val rollback = entryList.toMutableList()650 entryList.add(entry)651 persistOrRollback(rollback)652 entry653 }654655 656657658659660661662663664 suspend fun addEntries(drafts: List<VaultEntry>): Int = mutex.withLock {665 requireUnlocked()666 if (drafts.isEmpty()) return 0667 val rollback = entryList.toMutableList()668 val now = System.currentTimeMillis()669 for (draft in drafts) {670 val stamp = VaultCrypto.monotonicEntryTimestamp()671 entryList.add(672 withDerivedLast4(673 draft.copy(674 id = draft.id.ifBlank { UUID.randomUUID().toString() },675 createdAt = if (draft.createdAt > 0) draft.createdAt else now,676 modifiedAt = stamp,677 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,678 ),679 ),680 )681 }682 persistOrRollback(rollback)683 return drafts.size684 }685686 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {687 requireUnlocked()688 val idx = entryList.indexOfFirst { it.id == patch.id }689 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")690 val current = entryList[idx]691 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)692 693 val passwordChanged = patch.password != null && patch.password != current.password694 695 696 697 698 699 700 val priorPassword = current.password701 val shouldCapture = passwordChanged &&702 !priorPassword.isNullOrEmpty() &&703 current.passwordHistory?.firstOrNull()?.password != priorPassword704 val nextHistory = if (shouldCapture) {705 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +706 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)707 } else {708 current.passwordHistory709 }710 val updated = withDerivedLast4(patch.copy(711 createdAt = current.createdAt,712 modifiedAt = stamp,713 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,714 passwordHistory = nextHistory,715 ))716 val rollback = entryList.toMutableList()717 entryList[idx] = updated718 persistOrRollback(rollback)719 updated720 }721722 723 suspend fun deleteEntry(id: String) = mutex.withLock {724 requireUnlocked()725 val idx = entryList.indexOfFirst { it.id == id }726 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")727 val current = entryList[idx]728 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)729 val rollback = entryList.toMutableList()730 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)731 persistOrRollback(rollback)732 }733734 suspend fun restoreEntry(id: String) = mutex.withLock {735 requireUnlocked()736 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }737 if (idx == -1) throw IllegalStateException("deleted entry not found")738 val current = entryList[idx]739 val rollback = entryList.toMutableList()740 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))741 persistOrRollback(rollback)742 }743744 745 suspend fun purgeEntry(id: String) = mutex.withLock {746 requireUnlocked()747 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }748 if (idx == -1) throw IllegalStateException("deleted entry not found")749 val rollback = entryList.toMutableList()750 entryList.removeAt(idx)751 persistOrRollback(rollback)752 }753754 755 suspend fun purgeDeletedEntries(): Int = mutex.withLock {756 requireUnlocked()757 val rollback = entryList.toMutableList()758 val before = entryList.size759 entryList.removeAll { it.deletedAt != null }760 val removed = before - entryList.size761 if (removed > 0) persistOrRollback(rollback)762 removed763 }764765 766 suspend fun countDuplicateEntries(): Int = mutex.withLock {767 requireUnlocked()768 countDuplicateEntriesLocked()769 }770771 772773774775776 suspend fun deduplicateEntries(): Int = mutex.withLock {777 requireUnlocked()778 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")779 val active = entryList.filter { it.deletedAt == null }780 val keepIds = mutableSetOf<String>()781 active.groupBy { it.duplicateKey() }.values.forEach { group ->782 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id783 }784785 val rollback = entryList.toMutableList()786 var removed = 0787 entryList = entryList.map { entry ->788 if (entry.deletedAt == null && entry.id !in keepIds) {789 removed++790 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)791 entry.copy(deletedAt = stamp, modifiedAt = stamp)792 } else {793 entry794 }795 }.toMutableList()796 if (removed > 0) persistOrRollback(rollback)797 removed798 }799800 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }801 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }802 fun getDeletedEntries(): List<VaultEntry> =803 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }804805 806 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {807 val active = entryList.filter { it.deletedAt == null }808 if (tagFilter == null) return active809 val needle = tagFilter.trim().lowercase()810 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }811 }812813 814 fun getDistinctTags(): List<String> =815 entryList.asSequence()816 .filter { it.deletedAt == null }817 .flatMap { it.tags?.asSequence() ?: emptySequence() }818 .map { it.trim().lowercase() }819 .filter { it.isNotEmpty() }820 .distinct()821 .sorted()822 .toList()823824 825826827828829830 fun getDistinctFieldValues(field: String): List<String> {831 val select: (VaultEntry) -> String? = when (field) {832 "username" -> { e -> e.username }833 "email" -> { e -> e.email }834 else -> return emptyList()835 }836 return entryList.asSequence()837 .filter { it.deletedAt == null }838 .mapNotNull { select(it)?.trim() }839 .filter { it.isNotEmpty() }840 .groupingBy { it.lowercase() }841 .aggregate { _, acc: Pair<String, Int>?, value, _ ->842 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)843 }844 .values845 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })846 .map { it.first }847 }848849 fun getTombstones(): List<Tombstone> =850 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }851 fun getVaultVersion(): Long = store.version()852 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots853854 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {855 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false856 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false857 takeVaultSnapshot("restore")858 if (!store.writeSlotA(restoredA)) return false859 if (!store.writeSlotB(snapshot.vault_b)) return false860 store.stampLocalMutation()861 lock()862 true863 }864865 866867 868869870871872 suspend fun applyMergedRemoteEntries(873 remoteEntries: List<VaultEntry>,874 remoteTombstones: List<Tombstone>,875 ): Int = mutex.withLock {876 requireUnlocked()877 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }878 var applied = 0879 val byId = entryList.associateBy { it.id }.toMutableMap()880881 for (remote in remoteEntries) {882 val tomb = tombMap[remote.id]883 if (tomb != null && tomb >= remote.modifiedAt) continue884 val local = byId[remote.id]885 if (local == null) {886 entryList.add(remote); byId[remote.id] = remote; applied++887 } else if (remote.modifiedAt > local.modifiedAt) {888 val i = entryList.indexOfFirst { it.id == local.id }889 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }890 }891 }892893 for ((id, deletedAt) in tombMap) {894 val local = byId[id] ?: continue895 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue896 if (deletedAt < local.modifiedAt) continue897 val i = entryList.indexOfFirst { it.id == id }898 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }899 }900901 if (applied > 0) {902 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")903 publish()904 }905 applied906 }907908 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {909 _settings.value = settings910 store.writeSettings(settings)911 }912913 914915 916 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet917918 919920921922923 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {924 requireUnlocked()925 recoveryWordHash = recoveryWordsHash(words)926 recoveryWordsSet = true927 928 929 930 931 runCatching {932 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))933 }.getOrDefault(false)934 }935936 937 fun verifyRecoveryWords(words: List<String>): Boolean {938 val stored = recoveryWordHash ?: return false939 return stored == recoveryWordsHash(words)940 }941942 943944 945 fun hasRecoveryEscrow(): Boolean =946 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false947948 949950951952953954955956957958959960 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {961 val slotA = store.loadSlotA() ?: return false962 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false963964 965 966 slotA.recoveryEscrow?.let { escrow ->967 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let968 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))969 val keyBytes = recoveryKey.encoded970 try {971 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)972 val payload = VaultCodec.decode(plaintext)973 decoyMode = false974 entryList = payload.entries.toMutableList()975 duressConfigured = payload.duressConfigured976 recoveryWordHash = payload.recoveryWordHash977 recoveryWordsSet = payload.recoveryWordsSet978 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)979 else generateRecoverableRootKey()980 _settings.value = store.loadSettings()981 VaultCrypto.zero(plaintext)982 return true983 } catch (_: Exception) {984 985 } finally {986 VaultCrypto.zero(material)987 VaultCrypto.zero(keyBytes)988 }989 }990991 992 993 994 slotA.rootKeyEscrow?.let { escrow ->995 val rk = unsealRootKeyEscrow(words, escrow) ?: return false996 decoyMode = false997 entryList = mutableListOf()998 duressConfigured = false999 recoveryWordHash = recoveryWordsHash(words)1000 recoveryWordsSet = true1001 rootKey = rk1002 _settings.value = store.loadSettings()1003 return true1004 }1005 return false1006 }10071008 100910101011101210131014101510161017 suspend fun resetPasswordWithRecoveryWords(1018 words: List<String>,1019 newPassword: String,1020 ): Boolean = mutex.withLock {1021 if (rootKey == null) return false 1022 return try {1023 val newSalt = VaultCrypto.generateSalt()1024 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)1025 val plaintext = VaultCodec.encode(currentPayload())1026 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))1027 val prior = store.loadSlotA()1028 1029 1030 1031 val valid = words.size == 12 && findInvalidWords(words).isEmpty()1032 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow1033 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow1034 val next = StoredVault(1035 version = prior?.version ?: 1,1036 salt = b64(newSalt),1037 blob = blob,1038 recoveryEscrow = freshRecovery,1039 rootKeyEscrow = freshRootKey,1040 )1041 if (!store.writeSlotA(next)) return false1042 writeMatchingNoiseSlotB(next)1043 store.stampLocalMutation()1044 derivedKey?.let(VaultCrypto::zero)1045 derivedKey = newKey1046 VaultCrypto.zero(plaintext)1047 publish()1048 _isUnlocked.value = true1049 true1050 } catch (_: Exception) {1051 false1052 }1053 }10541055 105610571058105910601061 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {1062 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1063 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1064 val salt = VaultCrypto.generateSalt()1065 val recoveryKey = deriveRecoveryKey(material, salt)1066 val keyBytes = recoveryKey.encoded1067 val plaintext = VaultCodec.encode(currentPayload())1068 return try {1069 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)1070 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))1071 } finally {1072 VaultCrypto.zero(material)1073 VaultCrypto.zero(keyBytes)1074 VaultCrypto.zero(plaintext)1075 }1076 }10771078 10791080108110821083108410851086 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {1087 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null1088 val rk = rootKey ?: return null1089 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1090 val salt = VaultCrypto.generateSalt()1091 val recoveryKey = deriveRecoveryKey(material, salt)1092 val keyBytes = recoveryKey.encoded1093 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)1094 return try {1095 1096 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1097 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)1098 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)1099 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)1100 val ct = ctTag.copyOfRange(0, ctTag.size - 16)1101 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))1102 } finally {1103 VaultCrypto.zero(material)1104 VaultCrypto.zero(keyBytes)1105 VaultCrypto.zero(plaintext)1106 }1107 }11081109 1110 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {1111 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null1112 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))1113 val keyBytes = recoveryKey.encoded1114 return try {1115 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)1116 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)1117 hexToRootKey(String(pt, Charsets.US_ASCII))1118 } catch (_: Exception) {1119 null1120 } finally {1121 VaultCrypto.zero(material)1122 VaultCrypto.zero(keyBytes)1123 }1124 }11251126 112711281129113011311132 private fun persistSlotAWithEscrows(1133 recoveryEscrow: RecoveryEscrow?,1134 rootKeyEscrow: RootKeyEscrow?,1135 ): Boolean {1136 val key = derivedKey ?: throw IllegalStateException("vault_locked")1137 if (decoyMode) return false1138 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1139 val plaintext = VaultCodec.encode(currentPayload())1140 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1141 val next = prior.copy(1142 blob = blob,1143 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,1144 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,1145 )1146 if (!store.writeSlotA(next)) return false1147 try {1148 writeMatchingNoiseSlotB(next)1149 } catch (e: Exception) {1150 store.writeSlotA(prior)1151 throw e1152 }1153 return true1154 }11551156 11571158 private fun requireUnlocked() {1159 if (derivedKey == null) throw IllegalStateException("vault_locked")1160 }11611162 private fun countDuplicateEntriesLocked(): Int {1163 val seen = mutableMapOf<String, VaultEntry>()1164 var duplicates = 01165 for (entry in entryList.filter { it.deletedAt == null }) {1166 val key = entry.duplicateKey()1167 val existing = seen[key]1168 if (existing == null) {1169 seen[key] = entry1170 } else {1171 duplicates++1172 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1173 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1174 if (entryStamp > existingStamp) seen[key] = entry1175 }1176 }1177 return duplicates1178 }11791180 private fun VaultEntry.duplicateKey(): String {1181 if (type == "note") return "note|${title.lowercase()}"1182 val passkeyData = passkey1183 if (passkeyData != null) {1184 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1185 }1186 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1187 }11881189 private fun takeVaultSnapshot(reason: String) {1190 runCatching {1191 val slotA = store.loadSlotA() ?: return1192 val vaultA = VaultCodec.encodeSlot(slotA)1193 val vaultB = store.loadSlotB() ?: slotA.blob1194 val history = store.loadVaultHistory()1195 val latest = history.snapshots.firstOrNull()1196 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1197 val entryCount = if (derivedKey != null && !decoyMode) {1198 bucketEntryCount(entryList.count { it.deletedAt == null })1199 } else {1200 null1201 }1202 val snapshot = VaultSnapshot(1203 id = UUID.randomUUID().toString(),1204 savedAt = System.currentTimeMillis(),1205 reason = reason,1206 vault_a = vaultA,1207 vault_b = vaultB,1208 entryCount = entryCount,1209 )1210 store.writeVaultHistory(1211 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1212 )1213 }1214 }12151216 private fun bucketEntryCount(n: Int): Int = when {1217 n <= 0 -> 01218 n <= 10 -> 101219 n <= 25 -> 251220 n <= 50 -> 501221 n <= 100 -> 1001222 n <= 250 -> 2501223 n <= 500 -> 5001224 else -> ((n + 499) / 500) * 5001225 }12261227 12281229123012311232 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1233 if (!entry.isCard()) return entry1234 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1235 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1236 }12371238 123912401241124212431244 private fun autoPurgeExpiredTrash() {1245 val days = _settings.value.trashRetentionDays ?: return1246 if (days <= 0) return1247 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1248 val before = entryList.size1249 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1250 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1251 }12521253 1254 private fun currentPayload(): VaultPayload =1255 VaultPayload(1256 entries = entryList.toList(),1257 duressConfigured = duressConfigured,1258 rootKeyB64 = rootKey?.let(::b64) ?: "",1259 recoveryWordHash = recoveryWordHash,1260 recoveryWordsSet = recoveryWordsSet,1261 )12621263 12641265126612671268 private fun ensureRootKey(payload: VaultPayload) {1269 if (payload.rootKeyB64.isNotBlank()) {1270 rootKey = unb64(payload.rootKeyB64)1271 return1272 }1273 rootKey = generateRecoverableRootKey()1274 runCatching { persistSlotA(bumpVersion = false) }1275 }12761277 private fun publish() {1278 _entries.value = entryList.filter { it.deletedAt == null }1279 }12801281 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1282 val plaintext = VaultCodec.encode(currentPayload())1283 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1284 return StoredVault(version = 1, salt = b64(salt), blob = blob)1285 }12861287 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1288 1289 1290 1291 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1292 val noise = ByteArray(approxBytes).also(rng::nextBytes)1293 store.writeSlotB(b64(noise))1294 }12951296 1297 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1298 val key = derivedKey ?: throw IllegalStateException("vault_locked")1299 1300 if (decoyMode) {1301 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1302 val plaintext = VaultCodec.encode(currentPayload())1303 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1304 return store.writeDecoySlot(prior.copy(blob = blob))1305 }1306 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1307 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1308 val plaintext = VaultCodec.encode(currentPayload())1309 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1310 val next = prior.copy(blob = blob)1311 if (!store.writeSlotA(next)) return false1312 1313 1314 try {1315 writeMatchingNoiseSlotB(next)1316 if (bumpVersion) store.stampLocalMutation()1317 } catch (e: Exception) {1318 store.writeSlotA(prior)1319 throw e1320 }1321 return true1322 }13231324 1325 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1326 try {1327 if (!persistSlotA(bumpVersion = true)) {1328 entryList = rollback1329 throw IllegalStateException("storage_write_failed")1330 }1331 } catch (e: Exception) {1332 entryList = rollback1333 throw e1334 }1335 publish()1336 }1337}