1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)171819private const val PASSWORD_HISTORY_MAX = 5020private const val VAULT_HISTORY_MAX = 52122sealed interface UnlockResult {23 data object Success : UnlockResult24 data object WrongPassword : UnlockResult25 data class Error(val reason: String) : UnlockResult26}2728sealed interface CreateVaultResult {29 data object Success : CreateVaultResult30 data class Error(val reason: String) : CreateVaultResult31}323334353637383940414243444546@Singleton47class VaultRepository @Inject constructor(48 private val store: VaultStorage,49) {50 private val mutex = Mutex()51 private val rng = SecureRandom()5253 private var derivedKey: ByteArray? = null54 private var rootKey: ByteArray? = null55 private var entryList: MutableList<VaultEntry> = mutableListOf()56 private var duressConfigured: Boolean = false57 private var recoveryWordHash: String? = null58 private var recoveryWordsSet: Boolean = false59 60 private var decoyMode: Boolean = false6162 private val _isUnlocked = MutableStateFlow(false)63 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6465 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())66 67 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6869 private val _settings = MutableStateFlow(VaultSettings())70 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7172 fun hasVault(): Boolean = store.hasVault()7374 7576 7778798081 fun codexVaultBlobBase64(): String? {82 val slotA = store.loadSlotA() ?: return null83 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""84 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))85 }8687 8889909192 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {93 return try {94 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)95 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)96 ?: return UnlockResult.Error("invalid_codex_blob")97 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")98 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")99 writeMatchingNoiseSlotB(slotA)100 store.stampLocalMutation()101 unlockLocked(password)102 } catch (e: Exception) {103 UnlockResult.Error(e.message ?: "restore_failed")104 }105 }106107 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {108 return try {109 val salt = VaultCrypto.generateSalt()110 val key = VaultCrypto.deriveKey(password, salt)111 entryList = mutableListOf()112 duressConfigured = false113 recoveryWordHash = null114 recoveryWordsSet = false115 decoyMode = false116 rootKey = generateRecoverableRootKey()117 val slotA = sealSlotA(key, salt)118 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")119 writeMatchingNoiseSlotB(slotA)120 store.stampLocalMutation()121 store.writeSettings(_settings.value)122 derivedKey = key123 publish()124 _isUnlocked.value = true125 CreateVaultResult.Success126 } catch (e: Exception) {127 CreateVaultResult.Error(e.message ?: "create_failed")128 }129 }130131 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }132133 134 private fun unlockLocked(password: String): UnlockResult {135 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")136 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))137 val payload = try {138 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))139 } catch (_: Exception) {140 VaultCrypto.zero(key)141 142 143 144 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {145 val legacy = tryLegacyRawUnlock(password, slotA)146 if (legacy != null) return legacy147 }148 return UnlockResult.WrongPassword149 }150 return adoptSlotA(key, payload)151 }152153 154155156157158159 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {160 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))161 val payload = try {162 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))163 } catch (_: Exception) {164 VaultCrypto.zero(rawKey)165 return null166 }167 168 169 val newSalt = VaultCrypto.generateSalt()170 val newKey = VaultCrypto.deriveKey(password, newSalt)171 val healed = try {172 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))173 val next = StoredVault(174 version = slotA.version,175 salt = b64(newSalt),176 blob = blob,177 recoveryEscrow = slotA.recoveryEscrow,178 rootKeyEscrow = slotA.rootKeyEscrow,179 )180 if (store.writeSlotA(next)) {181 writeMatchingNoiseSlotB(next)182 store.stampLocalMutation()183 true184 } else {185 false186 }187 } catch (_: Exception) {188 false189 }190 return if (healed) {191 VaultCrypto.zero(rawKey)192 adoptSlotA(newKey, payload) 193 } else {194 VaultCrypto.zero(newKey)195 adoptSlotA(rawKey, payload) 196 }197 }198199 200 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {201 return try {202 derivedKey = key203 decoyMode = false204 entryList = payload.entries.toMutableList()205 duressConfigured = payload.duressConfigured206 recoveryWordHash = payload.recoveryWordHash207 recoveryWordsSet = payload.recoveryWordsSet208 _settings.value = store.loadSettings()209 ensureRootKey(payload)210 autoPurgeExpiredTrash()211 publish()212 _isUnlocked.value = true213 UnlockResult.Success214 } catch (e: Exception) {215 UnlockResult.Error(e.message ?: "unlock_failed")216 }217 }218219 220221 222223224225226227 class SlotAUnlock internal constructor(228 internal val key: ByteArray,229 internal val payload: VaultPayload,230 )231232 233234235236237238 fun tryUnlockSlotA(password: String): SlotAUnlock? {239 val slotA = store.loadSlotA() ?: return null240 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))241 return try {242 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))243 SlotAUnlock(key, payload)244 } catch (_: Exception) {245 VaultCrypto.zero(key)246 null247 }248 }249250 251 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {252 adoptSlotA(unlock.key, unlock.payload)253 }254255 256 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)257258 259260 261262263264265 class DuressUnlock internal constructor(266 internal val key: ByteArray,267 internal val payload: VaultPayload,268 )269270 271272273274275276277 fun tryUnlockDuress(password: String): DuressUnlock? {278 val slot = store.loadDecoySlot() ?: return null279 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))280 return try {281 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))282 DuressUnlock(key, payload)283 } catch (_: Exception) {284 VaultCrypto.zero(key)285 null286 }287 }288289 290 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {291 return try {292 derivedKey = unlock.key293 decoyMode = true294 entryList = unlock.payload.entries.toMutableList()295 duressConfigured = false296 recoveryWordHash = unlock.payload.recoveryWordHash297 recoveryWordsSet = unlock.payload.recoveryWordsSet298 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)299 else VaultCrypto.generateRootKey()300 _settings.value = store.loadSettings()301 publish()302 _isUnlocked.value = true303 UnlockResult.Success304 } catch (e: Exception) {305 UnlockResult.Error(e.message ?: "decoy_unlock_failed")306 }307 }308309 310 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)311312 313314315316317318 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {319 if (derivedKey == null || decoyMode) return false320 321 322 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }323 return try {324 val salt = VaultCrypto.generateSalt()325 val key = VaultCrypto.deriveKey(duressPassphrase, salt)326 327 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))328 val plaintext = VaultCodec.encode(decoyPayload)329 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))330 VaultCrypto.zero(key)331 VaultCrypto.zero(plaintext)332 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))333 if (ok) {334 duressConfigured = true335 runCatching { persistSlotA(bumpVersion = false) }336 }337 ok338 } catch (_: Exception) {339 false340 }341 }342343 344 suspend fun clearDuress(): Boolean = mutex.withLock {345 store.clearDecoySlot()346 if (derivedKey != null && !decoyMode) {347 duressConfigured = false348 runCatching { persistSlotA(bumpVersion = false) }349 }350 true351 }352353 354 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {355 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")356 return try {357 val payload = VaultCodec.decode(358 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),359 )360 derivedKey = key.copyOf()361 decoyMode = false362 entryList = payload.entries.toMutableList()363 duressConfigured = payload.duressConfigured364 recoveryWordHash = payload.recoveryWordHash365 recoveryWordsSet = payload.recoveryWordsSet366 _settings.value = store.loadSettings()367 ensureRootKey(payload)368 autoPurgeExpiredTrash()369 publish()370 _isUnlocked.value = true371 UnlockResult.Success372 } catch (_: Exception) {373 UnlockResult.WrongPassword374 }375 }376377 fun lock() {378 derivedKey?.let(VaultCrypto::zero)379 rootKey?.let(VaultCrypto::zero)380 derivedKey = null381 rootKey = null382 entryList = mutableListOf()383 duressConfigured = false384 recoveryWordHash = null385 recoveryWordsSet = false386 decoyMode = false387 _entries.value = emptyList()388 _isUnlocked.value = false389 }390391 392393 394395396397398 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()399400 401 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }402403 404405406407408 suspend fun rotateSyncIdentity(): Boolean = mutex.withLock {409 val key = derivedKey ?: return false410 if (decoyMode) return false411 val priorSlot = store.loadSlotA() ?: return false412 val priorRootKey = rootKey ?: return false413 val priorRecoveryHash = recoveryWordHash414 val priorRecoverySet = recoveryWordsSet415 val nextRootKey = generateRecoverableRootKey()416 val nextWords = rootKeyToWords(nextRootKey)417418 try {419 takeVaultSnapshot("local_save")420 rootKey = nextRootKey421 recoveryWordHash = recoveryWordsHash(nextWords)422 recoveryWordsSet = false423424 val nextRecoveryEscrow = buildRecoveryEscrow(nextWords)425 ?: throw IllegalStateException("recovery_escrow_failed")426 val nextRootKeyEscrow = buildRootKeyEscrow(nextWords)427 ?: throw IllegalStateException("root_escrow_failed")428 val plaintext = VaultCodec.encode(currentPayload())429 val blob = try {430 b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))431 } finally {432 VaultCrypto.zero(plaintext)433 }434 val nextSlot = priorSlot.copy(435 blob = blob,436 recoveryEscrow = nextRecoveryEscrow,437 rootKeyEscrow = nextRootKeyEscrow,438 )439 if (!store.writeSlotA(nextSlot)) throw IllegalStateException("storage_write_failed")440 try {441 writeMatchingNoiseSlotB(nextSlot)442 store.stampLocalMutation()443 } catch (e: Exception) {444 store.writeSlotA(priorSlot)445 throw e446 }447 VaultCrypto.zero(priorRootKey)448 publish()449 true450 } catch (_: Exception) {451 rootKey = priorRootKey452 recoveryWordHash = priorRecoveryHash453 recoveryWordsSet = priorRecoverySet454 VaultCrypto.zero(nextRootKey)455 store.writeSlotA(priorSlot)456 false457 }458 }459460 461462463464465 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()466467 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {468 val slotA = store.loadSlotA() ?: return false469 return try {470 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))471 val payload = VaultCodec.decode(472 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),473 )474 val newSalt = VaultCrypto.generateSalt()475 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)476 477 val plaintext = VaultCodec.encode(payload)478 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))479 480 481 482 483 484 485 486 val next = StoredVault(487 version = slotA.version,488 salt = b64(newSalt),489 blob = blob,490 recoveryEscrow = slotA.recoveryEscrow,491 rootKeyEscrow = slotA.rootKeyEscrow,492 )493 if (!store.writeSlotA(next)) return false494 writeMatchingNoiseSlotB(next)495 store.stampLocalMutation()496 VaultCrypto.zero(oldKey)497 derivedKey?.let(VaultCrypto::zero)498 derivedKey = newKey499 true500 } catch (_: Exception) {501 false502 }503 }504505 506507508509 suspend fun destroyVault() = mutex.withLock {510 lock()511 store.clearVault()512 store.clearDecoySlot()513 }514515 516517 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {518 requireUnlocked()519 val stamp = VaultCrypto.monotonicEntryTimestamp()520 val now = System.currentTimeMillis()521 val entry = withDerivedLast4(draft.copy(522 id = draft.id.ifBlank { UUID.randomUUID().toString() },523 createdAt = if (draft.createdAt > 0) draft.createdAt else now,524 modifiedAt = stamp,525 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,526 ))527 val rollback = entryList.toMutableList()528 entryList.add(entry)529 persistOrRollback(rollback)530 entry531 }532533 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {534 requireUnlocked()535 val idx = entryList.indexOfFirst { it.id == patch.id }536 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")537 val current = entryList[idx]538 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)539 540 val passwordChanged = patch.password != null && patch.password != current.password541 542 543 544 545 546 547 val priorPassword = current.password548 val shouldCapture = passwordChanged &&549 !priorPassword.isNullOrEmpty() &&550 current.passwordHistory?.firstOrNull()?.password != priorPassword551 val nextHistory = if (shouldCapture) {552 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +553 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)554 } else {555 current.passwordHistory556 }557 val updated = withDerivedLast4(patch.copy(558 createdAt = current.createdAt,559 modifiedAt = stamp,560 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,561 passwordHistory = nextHistory,562 ))563 val rollback = entryList.toMutableList()564 entryList[idx] = updated565 persistOrRollback(rollback)566 updated567 }568569 570 suspend fun deleteEntry(id: String) = mutex.withLock {571 requireUnlocked()572 val idx = entryList.indexOfFirst { it.id == id }573 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")574 val current = entryList[idx]575 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)576 val rollback = entryList.toMutableList()577 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)578 persistOrRollback(rollback)579 }580581 suspend fun restoreEntry(id: String) = mutex.withLock {582 requireUnlocked()583 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }584 if (idx == -1) throw IllegalStateException("deleted entry not found")585 val current = entryList[idx]586 val rollback = entryList.toMutableList()587 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))588 persistOrRollback(rollback)589 }590591 592 suspend fun purgeEntry(id: String) = mutex.withLock {593 requireUnlocked()594 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }595 if (idx == -1) throw IllegalStateException("deleted entry not found")596 val rollback = entryList.toMutableList()597 entryList.removeAt(idx)598 persistOrRollback(rollback)599 }600601 602 suspend fun purgeDeletedEntries(): Int = mutex.withLock {603 requireUnlocked()604 val rollback = entryList.toMutableList()605 val before = entryList.size606 entryList.removeAll { it.deletedAt != null }607 val removed = before - entryList.size608 if (removed > 0) persistOrRollback(rollback)609 removed610 }611612 613 suspend fun countDuplicateEntries(): Int = mutex.withLock {614 requireUnlocked()615 countDuplicateEntriesLocked()616 }617618 619620621622623 suspend fun deduplicateEntries(): Int = mutex.withLock {624 requireUnlocked()625 if (decoyMode) throw IllegalStateException("deduplicate disabled in decoy mode")626 val active = entryList.filter { it.deletedAt == null }627 val keepIds = mutableSetOf<String>()628 active.groupBy { it.duplicateKey() }.values.forEach { group ->629 keepIds += group.maxBy { it.modifiedAt.takeIf { ts -> ts > 0 } ?: it.createdAt }.id630 }631632 val rollback = entryList.toMutableList()633 var removed = 0634 entryList = entryList.map { entry ->635 if (entry.deletedAt == null && entry.id !in keepIds) {636 removed++637 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)638 entry.copy(deletedAt = stamp, modifiedAt = stamp)639 } else {640 entry641 }642 }.toMutableList()643 if (removed > 0) persistOrRollback(rollback)644 removed645 }646647 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }648 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }649 fun getDeletedEntries(): List<VaultEntry> =650 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }651652 653 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {654 val active = entryList.filter { it.deletedAt == null }655 if (tagFilter == null) return active656 val needle = tagFilter.trim().lowercase()657 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }658 }659660 661 fun getDistinctTags(): List<String> =662 entryList.asSequence()663 .filter { it.deletedAt == null }664 .flatMap { it.tags?.asSequence() ?: emptySequence() }665 .map { it.trim().lowercase() }666 .filter { it.isNotEmpty() }667 .distinct()668 .sorted()669 .toList()670671 672673674675676677 fun getDistinctFieldValues(field: String): List<String> {678 val select: (VaultEntry) -> String? = when (field) {679 "username" -> { e -> e.username }680 "email" -> { e -> e.email }681 else -> return emptyList()682 }683 return entryList.asSequence()684 .filter { it.deletedAt == null }685 .mapNotNull { select(it)?.trim() }686 .filter { it.isNotEmpty() }687 .groupingBy { it.lowercase() }688 .aggregate { _, acc: Pair<String, Int>?, value, _ ->689 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)690 }691 .values692 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })693 .map { it.first }694 }695696 fun getTombstones(): List<Tombstone> =697 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }698 fun getVaultVersion(): Long = store.version()699 fun getVaultHistory(): List<VaultSnapshot> = store.loadVaultHistory().snapshots700701 suspend fun restoreVaultSnapshot(snapshotId: String): Boolean = mutex.withLock {702 val snapshot = store.loadVaultHistory().snapshots.firstOrNull { it.id == snapshotId } ?: return false703 val restoredA = VaultCodec.decodeSlot(snapshot.vault_a) ?: return false704 takeVaultSnapshot("restore")705 if (!store.writeSlotA(restoredA)) return false706 if (!store.writeSlotB(snapshot.vault_b)) return false707 store.stampLocalMutation()708 lock()709 true710 }711712 713714 715716717718719 suspend fun applyMergedRemoteEntries(720 remoteEntries: List<VaultEntry>,721 remoteTombstones: List<Tombstone>,722 ): Int = mutex.withLock {723 requireUnlocked()724 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }725 var applied = 0726 val byId = entryList.associateBy { it.id }.toMutableMap()727728 for (remote in remoteEntries) {729 val tomb = tombMap[remote.id]730 if (tomb != null && tomb >= remote.modifiedAt) continue731 val local = byId[remote.id]732 if (local == null) {733 entryList.add(remote); byId[remote.id] = remote; applied++734 } else if (remote.modifiedAt > local.modifiedAt) {735 val i = entryList.indexOfFirst { it.id == local.id }736 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }737 }738 }739740 for ((id, deletedAt) in tombMap) {741 val local = byId[id] ?: continue742 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue743 if (deletedAt < local.modifiedAt) continue744 val i = entryList.indexOfFirst { it.id == id }745 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }746 }747748 if (applied > 0) {749 persistSlotA(bumpVersion = false, snapshotReason = "sync_received")750 publish()751 }752 applied753 }754755 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {756 _settings.value = settings757 store.writeSettings(settings)758 }759760 761762 763 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet764765 766767768769770 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {771 requireUnlocked()772 recoveryWordHash = recoveryWordsHash(words)773 recoveryWordsSet = true774 775 776 777 778 runCatching {779 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))780 }.getOrDefault(false)781 }782783 784 fun verifyRecoveryWords(words: List<String>): Boolean {785 val stored = recoveryWordHash ?: return false786 return stored == recoveryWordsHash(words)787 }788789 790791 792 fun hasRecoveryEscrow(): Boolean =793 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false794795 796797798799800801802803804805806807 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {808 val slotA = store.loadSlotA() ?: return false809 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false810811 812 813 slotA.recoveryEscrow?.let { escrow ->814 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let815 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))816 val keyBytes = recoveryKey.encoded817 try {818 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)819 val payload = VaultCodec.decode(plaintext)820 decoyMode = false821 entryList = payload.entries.toMutableList()822 duressConfigured = payload.duressConfigured823 recoveryWordHash = payload.recoveryWordHash824 recoveryWordsSet = payload.recoveryWordsSet825 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)826 else generateRecoverableRootKey()827 _settings.value = store.loadSettings()828 VaultCrypto.zero(plaintext)829 return true830 } catch (_: Exception) {831 832 } finally {833 VaultCrypto.zero(material)834 VaultCrypto.zero(keyBytes)835 }836 }837838 839 840 841 slotA.rootKeyEscrow?.let { escrow ->842 val rk = unsealRootKeyEscrow(words, escrow) ?: return false843 decoyMode = false844 entryList = mutableListOf()845 duressConfigured = false846 recoveryWordHash = recoveryWordsHash(words)847 recoveryWordsSet = true848 rootKey = rk849 _settings.value = store.loadSettings()850 return true851 }852 return false853 }854855 856857858859860861862863864 suspend fun resetPasswordWithRecoveryWords(865 words: List<String>,866 newPassword: String,867 ): Boolean = mutex.withLock {868 if (rootKey == null) return false 869 return try {870 val newSalt = VaultCrypto.generateSalt()871 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)872 val plaintext = VaultCodec.encode(currentPayload())873 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))874 val prior = store.loadSlotA()875 876 877 878 val valid = words.size == 12 && findInvalidWords(words).isEmpty()879 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow880 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow881 val next = StoredVault(882 version = prior?.version ?: 1,883 salt = b64(newSalt),884 blob = blob,885 recoveryEscrow = freshRecovery,886 rootKeyEscrow = freshRootKey,887 )888 if (!store.writeSlotA(next)) return false889 writeMatchingNoiseSlotB(next)890 store.stampLocalMutation()891 derivedKey?.let(VaultCrypto::zero)892 derivedKey = newKey893 VaultCrypto.zero(plaintext)894 publish()895 _isUnlocked.value = true896 true897 } catch (_: Exception) {898 false899 }900 }901902 903904905906907908 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {909 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null910 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null911 val salt = VaultCrypto.generateSalt()912 val recoveryKey = deriveRecoveryKey(material, salt)913 val keyBytes = recoveryKey.encoded914 val plaintext = VaultCodec.encode(currentPayload())915 return try {916 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)917 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))918 } finally {919 VaultCrypto.zero(material)920 VaultCrypto.zero(keyBytes)921 VaultCrypto.zero(plaintext)922 }923 }924925 926927928929930931932933 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {934 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null935 val rk = rootKey ?: return null936 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null937 val salt = VaultCrypto.generateSalt()938 val recoveryKey = deriveRecoveryKey(material, salt)939 val keyBytes = recoveryKey.encoded940 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)941 return try {942 943 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)944 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)945 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)946 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)947 val ct = ctTag.copyOfRange(0, ctTag.size - 16)948 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))949 } finally {950 VaultCrypto.zero(material)951 VaultCrypto.zero(keyBytes)952 VaultCrypto.zero(plaintext)953 }954 }955956 957 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {958 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null959 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))960 val keyBytes = recoveryKey.encoded961 return try {962 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)963 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)964 hexToRootKey(String(pt, Charsets.US_ASCII))965 } catch (_: Exception) {966 null967 } finally {968 VaultCrypto.zero(material)969 VaultCrypto.zero(keyBytes)970 }971 }972973 974975976977978979 private fun persistSlotAWithEscrows(980 recoveryEscrow: RecoveryEscrow?,981 rootKeyEscrow: RootKeyEscrow?,982 ): Boolean {983 val key = derivedKey ?: throw IllegalStateException("vault_locked")984 if (decoyMode) return false985 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")986 val plaintext = VaultCodec.encode(currentPayload())987 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))988 val next = prior.copy(989 blob = blob,990 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,991 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,992 )993 if (!store.writeSlotA(next)) return false994 try {995 writeMatchingNoiseSlotB(next)996 } catch (e: Exception) {997 store.writeSlotA(prior)998 throw e999 }1000 return true1001 }10021003 10041005 private fun requireUnlocked() {1006 if (derivedKey == null) throw IllegalStateException("vault_locked")1007 }10081009 private fun countDuplicateEntriesLocked(): Int {1010 val seen = mutableMapOf<String, VaultEntry>()1011 var duplicates = 01012 for (entry in entryList.filter { it.deletedAt == null }) {1013 val key = entry.duplicateKey()1014 val existing = seen[key]1015 if (existing == null) {1016 seen[key] = entry1017 } else {1018 duplicates++1019 val entryStamp = entry.modifiedAt.takeIf { it > 0 } ?: entry.createdAt1020 val existingStamp = existing.modifiedAt.takeIf { it > 0 } ?: existing.createdAt1021 if (entryStamp > existingStamp) seen[key] = entry1022 }1023 }1024 return duplicates1025 }10261027 private fun VaultEntry.duplicateKey(): String {1028 if (type == "note") return "note|${title.lowercase()}"1029 val passkeyData = passkey1030 if (passkeyData != null) {1031 return "passkey|${passkeyData.rpId.lowercase()}|${passkeyData.userName.lowercase()}"1032 }1033 return "login|${title.lowercase()}|${url.orEmpty().lowercase()}|${username.orEmpty().lowercase()}"1034 }10351036 private fun takeVaultSnapshot(reason: String) {1037 runCatching {1038 val slotA = store.loadSlotA() ?: return1039 val vaultA = VaultCodec.encodeSlot(slotA)1040 val vaultB = store.loadSlotB() ?: slotA.blob1041 val history = store.loadVaultHistory()1042 val latest = history.snapshots.firstOrNull()1043 if (latest?.vault_a == vaultA && latest.vault_b == vaultB) return1044 val entryCount = if (derivedKey != null && !decoyMode) {1045 bucketEntryCount(entryList.count { it.deletedAt == null })1046 } else {1047 null1048 }1049 val snapshot = VaultSnapshot(1050 id = UUID.randomUUID().toString(),1051 savedAt = System.currentTimeMillis(),1052 reason = reason,1053 vault_a = vaultA,1054 vault_b = vaultB,1055 entryCount = entryCount,1056 )1057 store.writeVaultHistory(1058 history.copy(snapshots = (listOf(snapshot) + history.snapshots).take(VAULT_HISTORY_MAX)),1059 )1060 }1061 }10621063 private fun bucketEntryCount(n: Int): Int = when {1064 n <= 0 -> 01065 n <= 10 -> 101066 n <= 25 -> 251067 n <= 50 -> 501068 n <= 100 -> 1001069 n <= 250 -> 2501070 n <= 500 -> 5001071 else -> ((n + 499) / 500) * 5001072 }10731074 10751076107710781079 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {1080 if (!entry.isCard()) return entry1081 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1082 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1083 }10841085 108610871088108910901091 private fun autoPurgeExpiredTrash() {1092 val days = _settings.value.trashRetentionDays ?: return1093 if (days <= 0) return1094 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L1095 val before = entryList.size1096 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }1097 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }1098 }10991100 1101 private fun currentPayload(): VaultPayload =1102 VaultPayload(1103 entries = entryList.toList(),1104 duressConfigured = duressConfigured,1105 rootKeyB64 = rootKey?.let(::b64) ?: "",1106 recoveryWordHash = recoveryWordHash,1107 recoveryWordsSet = recoveryWordsSet,1108 )11091110 11111112111311141115 private fun ensureRootKey(payload: VaultPayload) {1116 if (payload.rootKeyB64.isNotBlank()) {1117 rootKey = unb64(payload.rootKeyB64)1118 return1119 }1120 rootKey = generateRecoverableRootKey()1121 runCatching { persistSlotA(bumpVersion = false) }1122 }11231124 private fun publish() {1125 _entries.value = entryList.filter { it.deletedAt == null }1126 }11271128 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {1129 val plaintext = VaultCodec.encode(currentPayload())1130 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1131 return StoredVault(version = 1, salt = b64(salt), blob = blob)1132 }11331134 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {1135 1136 1137 1138 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)1139 val noise = ByteArray(approxBytes).also(rng::nextBytes)1140 store.writeSlotB(b64(noise))1141 }11421143 1144 private fun persistSlotA(bumpVersion: Boolean, snapshotReason: String? = "local_save"): Boolean {1145 val key = derivedKey ?: throw IllegalStateException("vault_locked")1146 1147 if (decoyMode) {1148 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")1149 val plaintext = VaultCodec.encode(currentPayload())1150 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1151 return store.writeDecoySlot(prior.copy(blob = blob))1152 }1153 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")1154 if (snapshotReason != null) takeVaultSnapshot(snapshotReason)1155 val plaintext = VaultCodec.encode(currentPayload())1156 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))1157 val next = prior.copy(blob = blob)1158 if (!store.writeSlotA(next)) return false1159 1160 1161 try {1162 writeMatchingNoiseSlotB(next)1163 if (bumpVersion) store.stampLocalMutation()1164 } catch (e: Exception) {1165 store.writeSlotA(prior)1166 throw e1167 }1168 return true1169 }11701171 1172 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1173 try {1174 if (!persistSlotA(bumpVersion = true)) {1175 entryList = rollback1176 throw IllegalStateException("storage_write_failed")1177 }1178 } catch (e: Exception) {1179 entryList = rollback1180 throw e1181 }1182 publish()1183 }1184}