1package com.peachpasswords.vault23import kotlinx.coroutines.flow.MutableStateFlow4import kotlinx.coroutines.flow.StateFlow5import kotlinx.coroutines.flow.asStateFlow6import kotlinx.coroutines.sync.Mutex7import kotlinx.coroutines.sync.withLock8import java.security.SecureRandom9import java.text.Normalizer10import java.util.Base6411import java.util.UUID12import javax.inject.Inject13import javax.inject.Singleton1415private fun b64(bytes: ByteArray): String = Base64.getEncoder().withoutPadding().encodeToString(bytes)16private fun unb64(s: String): ByteArray = Base64.getDecoder().decode(s)171819private const val PASSWORD_HISTORY_MAX = 502021sealed interface UnlockResult {22 data object Success : UnlockResult23 data object WrongPassword : UnlockResult24 data class Error(val reason: String) : UnlockResult25}2627sealed interface CreateVaultResult {28 data object Success : CreateVaultResult29 data class Error(val reason: String) : CreateVaultResult30}313233343536373839404142434445@Singleton46class VaultRepository @Inject constructor(47 private val store: VaultStorage,48) {49 private val mutex = Mutex()50 private val rng = SecureRandom()5152 private var derivedKey: ByteArray? = null53 private var rootKey: ByteArray? = null54 private var entryList: MutableList<VaultEntry> = mutableListOf()55 private var duressConfigured: Boolean = false56 private var recoveryWordHash: String? = null57 private var recoveryWordsSet: Boolean = false58 59 private var decoyMode: Boolean = false6061 private val _isUnlocked = MutableStateFlow(false)62 val isUnlocked: StateFlow<Boolean> = _isUnlocked.asStateFlow()6364 private val _entries = MutableStateFlow<List<VaultEntry>>(emptyList())65 66 val entries: StateFlow<List<VaultEntry>> = _entries.asStateFlow()6768 private val _settings = MutableStateFlow(VaultSettings())69 val settings: StateFlow<VaultSettings> = _settings.asStateFlow()7071 fun hasVault(): Boolean = store.hasVault()7273 7475 7677787980 fun codexVaultBlobBase64(): String? {81 val slotA = store.loadSlotA() ?: return null82 val wrapper = """{"a":${VaultCodec.encodeSlot(slotA)}}"""83 return Base64.getUrlEncoder().withoutPadding().encodeToString(wrapper.toByteArray(Charsets.UTF_8))84 }8586 8788899091 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {92 return try {93 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)94 val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*}\\s*$").find(wrapperJson)?.groupValues?.get(1)95 ?: return UnlockResult.Error("invalid_codex_blob")96 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")97 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")98 writeMatchingNoiseSlotB(slotA)99 store.stampLocalMutation()100 unlockLocked(password)101 } catch (e: Exception) {102 UnlockResult.Error(e.message ?: "restore_failed")103 }104 }105106 suspend fun createVault(password: String): CreateVaultResult = mutex.withLock {107 return try {108 val salt = VaultCrypto.generateSalt()109 val key = VaultCrypto.deriveKey(password, salt)110 entryList = mutableListOf()111 duressConfigured = false112 recoveryWordHash = null113 recoveryWordsSet = false114 decoyMode = false115 rootKey = generateRecoverableRootKey()116 val slotA = sealSlotA(key, salt)117 if (!store.writeSlotA(slotA)) return CreateVaultResult.Error("storage_write_failed")118 writeMatchingNoiseSlotB(slotA)119 store.stampLocalMutation()120 store.writeSettings(_settings.value)121 derivedKey = key122 publish()123 _isUnlocked.value = true124 CreateVaultResult.Success125 } catch (e: Exception) {126 CreateVaultResult.Error(e.message ?: "create_failed")127 }128 }129130 suspend fun unlock(password: String): UnlockResult = mutex.withLock { unlockLocked(password) }131132 133 private fun unlockLocked(password: String): UnlockResult {134 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")135 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))136 val payload = try {137 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))138 } catch (_: Exception) {139 VaultCrypto.zero(key)140 141 142 143 if (password != Normalizer.normalize(password, Normalizer.Form.NFC)) {144 val legacy = tryLegacyRawUnlock(password, slotA)145 if (legacy != null) return legacy146 }147 return UnlockResult.WrongPassword148 }149 return adoptSlotA(key, payload)150 }151152 153154155156157158 private fun tryLegacyRawUnlock(password: String, slotA: StoredVault): UnlockResult? {159 val rawKey = VaultCrypto.deriveKeyRaw(password, unb64(slotA.salt))160 val payload = try {161 VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), rawKey, VaultCrypto.AAD_SLOT_A))162 } catch (_: Exception) {163 VaultCrypto.zero(rawKey)164 return null165 }166 167 168 val newSalt = VaultCrypto.generateSalt()169 val newKey = VaultCrypto.deriveKey(password, newSalt)170 val healed = try {171 val blob = b64(VaultCrypto.encrypt(VaultCodec.encode(payload), newKey, VaultCrypto.AAD_SLOT_A))172 val next = StoredVault(173 version = slotA.version,174 salt = b64(newSalt),175 blob = blob,176 recoveryEscrow = slotA.recoveryEscrow,177 rootKeyEscrow = slotA.rootKeyEscrow,178 )179 if (store.writeSlotA(next)) {180 writeMatchingNoiseSlotB(next)181 store.stampLocalMutation()182 true183 } else {184 false185 }186 } catch (_: Exception) {187 false188 }189 return if (healed) {190 VaultCrypto.zero(rawKey)191 adoptSlotA(newKey, payload) 192 } else {193 VaultCrypto.zero(newKey)194 adoptSlotA(rawKey, payload) 195 }196 }197198 199 private fun adoptSlotA(key: ByteArray, payload: VaultPayload): UnlockResult {200 return try {201 derivedKey = key202 decoyMode = false203 entryList = payload.entries.toMutableList()204 duressConfigured = payload.duressConfigured205 recoveryWordHash = payload.recoveryWordHash206 recoveryWordsSet = payload.recoveryWordsSet207 _settings.value = store.loadSettings()208 ensureRootKey(payload)209 autoPurgeExpiredTrash()210 publish()211 _isUnlocked.value = true212 UnlockResult.Success213 } catch (e: Exception) {214 UnlockResult.Error(e.message ?: "unlock_failed")215 }216 }217218 219220 221222223224225226 class SlotAUnlock internal constructor(227 internal val key: ByteArray,228 internal val payload: VaultPayload,229 )230231 232233234235236237 fun tryUnlockSlotA(password: String): SlotAUnlock? {238 val slotA = store.loadSlotA() ?: return null239 val key = VaultCrypto.deriveKey(password, unb64(slotA.salt))240 return try {241 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A))242 SlotAUnlock(key, payload)243 } catch (_: Exception) {244 VaultCrypto.zero(key)245 null246 }247 }248249 250 suspend fun commitSlotAUnlock(unlock: SlotAUnlock): UnlockResult = mutex.withLock {251 adoptSlotA(unlock.key, unlock.payload)252 }253254 255 fun discardSlotAUnlock(unlock: SlotAUnlock) = VaultCrypto.zero(unlock.key)256257 258259 260261262263264 class DuressUnlock internal constructor(265 internal val key: ByteArray,266 internal val payload: VaultPayload,267 )268269 270271272273274275276 fun tryUnlockDuress(password: String): DuressUnlock? {277 val slot = store.loadDecoySlot() ?: return null278 val key = VaultCrypto.deriveKey(password, unb64(slot.salt))279 return try {280 val payload = VaultCodec.decode(VaultCrypto.decrypt(unb64(slot.blob), key, VaultCrypto.AAD_SLOT_A))281 DuressUnlock(key, payload)282 } catch (_: Exception) {283 VaultCrypto.zero(key)284 null285 }286 }287288 289 suspend fun commitDuressUnlock(unlock: DuressUnlock): UnlockResult = mutex.withLock {290 return try {291 derivedKey = unlock.key292 decoyMode = true293 entryList = unlock.payload.entries.toMutableList()294 duressConfigured = false295 recoveryWordHash = unlock.payload.recoveryWordHash296 recoveryWordsSet = unlock.payload.recoveryWordsSet297 rootKey = if (unlock.payload.rootKeyB64.isNotBlank()) unb64(unlock.payload.rootKeyB64)298 else VaultCrypto.generateRootKey()299 _settings.value = store.loadSettings()300 publish()301 _isUnlocked.value = true302 UnlockResult.Success303 } catch (e: Exception) {304 UnlockResult.Error(e.message ?: "decoy_unlock_failed")305 }306 }307308 309 fun discardDuressUnlock(unlock: DuressUnlock) = VaultCrypto.zero(unlock.key)310311 312313314315316317 suspend fun setDuressPassphrase(duressPassphrase: String): Boolean = mutex.withLock {318 if (derivedKey == null || decoyMode) return false319 320 321 tryUnlockSlotA(duressPassphrase)?.let { discardSlotAUnlock(it); return false }322 return try {323 val salt = VaultCrypto.generateSalt()324 val key = VaultCrypto.deriveKey(duressPassphrase, salt)325 326 val decoyPayload = VaultPayload(entries = emptyList(), rootKeyB64 = b64(VaultCrypto.generateRootKey()))327 val plaintext = VaultCodec.encode(decoyPayload)328 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))329 VaultCrypto.zero(key)330 VaultCrypto.zero(plaintext)331 val ok = store.writeDecoySlot(StoredVault(version = 1, salt = b64(salt), blob = blob))332 if (ok) {333 duressConfigured = true334 runCatching { persistSlotA(bumpVersion = false) }335 }336 ok337 } catch (_: Exception) {338 false339 }340 }341342 343 suspend fun clearDuress(): Boolean = mutex.withLock {344 store.clearDecoySlot()345 if (derivedKey != null && !decoyMode) {346 duressConfigured = false347 runCatching { persistSlotA(bumpVersion = false) }348 }349 true350 }351352 353 suspend fun unlockWithKey(key: ByteArray): UnlockResult = mutex.withLock {354 val slotA = store.loadSlotA() ?: return UnlockResult.Error("no_vault")355 return try {356 val payload = VaultCodec.decode(357 VaultCrypto.decrypt(unb64(slotA.blob), key, VaultCrypto.AAD_SLOT_A),358 )359 derivedKey = key.copyOf()360 decoyMode = false361 entryList = payload.entries.toMutableList()362 duressConfigured = payload.duressConfigured363 recoveryWordHash = payload.recoveryWordHash364 recoveryWordsSet = payload.recoveryWordsSet365 _settings.value = store.loadSettings()366 ensureRootKey(payload)367 autoPurgeExpiredTrash()368 publish()369 _isUnlocked.value = true370 UnlockResult.Success371 } catch (_: Exception) {372 UnlockResult.WrongPassword373 }374 }375376 fun lock() {377 derivedKey?.let(VaultCrypto::zero)378 rootKey?.let(VaultCrypto::zero)379 derivedKey = null380 rootKey = null381 entryList = mutableListOf()382 duressConfigured = false383 recoveryWordHash = null384 recoveryWordsSet = false385 decoyMode = false386 _entries.value = emptyList()387 _isUnlocked.value = false388 }389390 391392 393394395396397 fun syncMasterKey(): ByteArray? = rootKey?.copyOf()398399 400 fun syncVaultId(): String? = rootKey?.let { com.peachpasswords.sync.computeVaultId(it) }401402 403404405406407 fun currentVaultKey(): ByteArray? = derivedKey?.copyOf()408409 suspend fun changePassword(oldPassword: String, newPassword: String): Boolean = mutex.withLock {410 val slotA = store.loadSlotA() ?: return false411 return try {412 val oldKey = VaultCrypto.deriveKey(oldPassword, unb64(slotA.salt))413 val payload = VaultCodec.decode(414 VaultCrypto.decrypt(unb64(slotA.blob), oldKey, VaultCrypto.AAD_SLOT_A),415 )416 val newSalt = VaultCrypto.generateSalt()417 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)418 419 val plaintext = VaultCodec.encode(payload)420 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))421 422 423 424 425 426 427 428 val next = StoredVault(429 version = slotA.version,430 salt = b64(newSalt),431 blob = blob,432 recoveryEscrow = slotA.recoveryEscrow,433 rootKeyEscrow = slotA.rootKeyEscrow,434 )435 if (!store.writeSlotA(next)) return false436 writeMatchingNoiseSlotB(next)437 store.stampLocalMutation()438 VaultCrypto.zero(oldKey)439 derivedKey?.let(VaultCrypto::zero)440 derivedKey = newKey441 true442 } catch (_: Exception) {443 false444 }445 }446447 448449450451 suspend fun destroyVault() = mutex.withLock {452 lock()453 store.clearVault()454 store.clearDecoySlot()455 }456457 458459 suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {460 requireUnlocked()461 val stamp = VaultCrypto.monotonicEntryTimestamp()462 val now = System.currentTimeMillis()463 val entry = withDerivedLast4(draft.copy(464 id = draft.id.ifBlank { UUID.randomUUID().toString() },465 createdAt = if (draft.createdAt > 0) draft.createdAt else now,466 modifiedAt = stamp,467 passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,468 ))469 val rollback = entryList.toMutableList()470 entryList.add(entry)471 persistOrRollback(rollback)472 entry473 }474475 suspend fun updateEntry(patch: VaultEntry): VaultEntry = mutex.withLock {476 requireUnlocked()477 val idx = entryList.indexOfFirst { it.id == patch.id }478 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")479 val current = entryList[idx]480 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)481 482 val passwordChanged = patch.password != null && patch.password != current.password483 484 485 486 487 488 489 val priorPassword = current.password490 val shouldCapture = passwordChanged &&491 !priorPassword.isNullOrEmpty() &&492 current.passwordHistory?.firstOrNull()?.password != priorPassword493 val nextHistory = if (shouldCapture) {494 (listOf(PasswordHistoryItem(priorPassword!!, current.passwordChangedAt ?: current.createdAt)) +495 (current.passwordHistory ?: emptyList())).take(PASSWORD_HISTORY_MAX)496 } else {497 current.passwordHistory498 }499 val updated = withDerivedLast4(patch.copy(500 createdAt = current.createdAt,501 modifiedAt = stamp,502 passwordChangedAt = if (passwordChanged) stamp else current.passwordChangedAt,503 passwordHistory = nextHistory,504 ))505 val rollback = entryList.toMutableList()506 entryList[idx] = updated507 persistOrRollback(rollback)508 updated509 }510511 512 suspend fun deleteEntry(id: String) = mutex.withLock {513 requireUnlocked()514 val idx = entryList.indexOfFirst { it.id == id }515 if (idx == -1 || entryList[idx].deletedAt != null) throw IllegalStateException("entry_not_found")516 val current = entryList[idx]517 val stamp = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt)518 val rollback = entryList.toMutableList()519 entryList[idx] = current.copy(deletedAt = stamp, modifiedAt = stamp)520 persistOrRollback(rollback)521 }522523 suspend fun restoreEntry(id: String) = mutex.withLock {524 requireUnlocked()525 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }526 if (idx == -1) throw IllegalStateException("deleted entry not found")527 val current = entryList[idx]528 val rollback = entryList.toMutableList()529 entryList[idx] = current.copy(deletedAt = null, modifiedAt = VaultCrypto.monotonicEntryTimestamp(current.modifiedAt))530 persistOrRollback(rollback)531 }532533 534 suspend fun purgeEntry(id: String) = mutex.withLock {535 requireUnlocked()536 val idx = entryList.indexOfFirst { it.id == id && it.deletedAt != null }537 if (idx == -1) throw IllegalStateException("deleted entry not found")538 val rollback = entryList.toMutableList()539 entryList.removeAt(idx)540 persistOrRollback(rollback)541 }542543 544 suspend fun purgeDeletedEntries(): Int = mutex.withLock {545 requireUnlocked()546 val rollback = entryList.toMutableList()547 val before = entryList.size548 entryList.removeAll { it.deletedAt != null }549 val removed = before - entryList.size550 if (removed > 0) persistOrRollback(rollback)551 removed552 }553554 fun getEntries(): List<VaultEntry> = entryList.filter { it.deletedAt == null }555 fun getEntry(id: String): VaultEntry? = entryList.firstOrNull { it.id == id && it.deletedAt == null }556 fun getDeletedEntries(): List<VaultEntry> =557 entryList.filter { it.deletedAt != null }.sortedByDescending { it.deletedAt }558559 560 fun getActiveEntries(tagFilter: String? = null): List<VaultEntry> {561 val active = entryList.filter { it.deletedAt == null }562 if (tagFilter == null) return active563 val needle = tagFilter.trim().lowercase()564 return active.filter { e -> e.tags?.any { it.equals(needle, ignoreCase = true) } == true }565 }566567 568 fun getDistinctTags(): List<String> =569 entryList.asSequence()570 .filter { it.deletedAt == null }571 .flatMap { it.tags?.asSequence() ?: emptySequence() }572 .map { it.trim().lowercase() }573 .filter { it.isNotEmpty() }574 .distinct()575 .sorted()576 .toList()577578 579580581582583584 fun getDistinctFieldValues(field: String): List<String> {585 val select: (VaultEntry) -> String? = when (field) {586 "username" -> { e -> e.username }587 "email" -> { e -> e.email }588 else -> return emptyList()589 }590 return entryList.asSequence()591 .filter { it.deletedAt == null }592 .mapNotNull { select(it)?.trim() }593 .filter { it.isNotEmpty() }594 .groupingBy { it.lowercase() }595 .aggregate { _, acc: Pair<String, Int>?, value, _ ->596 Pair(acc?.first ?: value, (acc?.second ?: 0) + 1)597 }598 .values599 .sortedWith(compareByDescending<Pair<String, Int>> { it.second }.thenBy { it.first.lowercase() })600 .map { it.first }601 }602603 fun getTombstones(): List<Tombstone> =604 entryList.filter { it.deletedAt != null }.map { Tombstone(it.id, it.deletedAt!!) }605 fun getVaultVersion(): Long = store.version()606607 608609 610611612613614 suspend fun applyMergedRemoteEntries(615 remoteEntries: List<VaultEntry>,616 remoteTombstones: List<Tombstone>,617 ): Int = mutex.withLock {618 requireUnlocked()619 val tombMap = remoteTombstones.associate { it.id to it.deletedAt }620 var applied = 0621 val byId = entryList.associateBy { it.id }.toMutableMap()622623 for (remote in remoteEntries) {624 val tomb = tombMap[remote.id]625 if (tomb != null && tomb >= remote.modifiedAt) continue626 val local = byId[remote.id]627 if (local == null) {628 entryList.add(remote); byId[remote.id] = remote; applied++629 } else if (remote.modifiedAt > local.modifiedAt) {630 val i = entryList.indexOfFirst { it.id == local.id }631 if (i != -1) { entryList[i] = remote; byId[remote.id] = remote; applied++ }632 }633 }634635 for ((id, deletedAt) in tombMap) {636 val local = byId[id] ?: continue637 if (local.deletedAt != null && local.deletedAt >= deletedAt) continue638 if (deletedAt < local.modifiedAt) continue639 val i = entryList.indexOfFirst { it.id == id }640 if (i != -1) { entryList[i] = local.copy(deletedAt = deletedAt, modifiedAt = deletedAt); applied++ }641 }642643 if (applied > 0) {644 persistSlotA(bumpVersion = false)645 publish()646 }647 applied648 }649650 suspend fun updateSettings(settings: VaultSettings) = mutex.withLock {651 _settings.value = settings652 store.writeSettings(settings)653 }654655 656657 658 fun isRecoveryWordsSet(): Boolean = recoveryWordsSet659660 661662663664665 suspend fun setRecoveryWords(words: List<String>): Boolean = mutex.withLock {666 requireUnlocked()667 recoveryWordHash = recoveryWordsHash(words)668 recoveryWordsSet = true669 670 671 672 673 runCatching {674 persistSlotAWithEscrows(buildRecoveryEscrow(words), buildRootKeyEscrow(words))675 }.getOrDefault(false)676 }677678 679 fun verifyRecoveryWords(words: List<String>): Boolean {680 val stored = recoveryWordHash ?: return false681 return stored == recoveryWordsHash(words)682 }683684 685686 687 fun hasRecoveryEscrow(): Boolean =688 store.loadSlotA()?.let { it.recoveryEscrow != null || it.rootKeyEscrow != null } ?: false689690 691692693694695696697698699700701702 suspend fun unlockWithRecoveryWords(words: List<String>): Boolean = mutex.withLock {703 val slotA = store.loadSlotA() ?: return false704 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return false705706 707 708 slotA.recoveryEscrow?.let { escrow ->709 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return@let710 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))711 val keyBytes = recoveryKey.encoded712 try {713 val plaintext = VaultCrypto.decrypt(unb64(escrow.blob), keyBytes, VaultCrypto.AAD_RECOVERY)714 val payload = VaultCodec.decode(plaintext)715 decoyMode = false716 entryList = payload.entries.toMutableList()717 duressConfigured = payload.duressConfigured718 recoveryWordHash = payload.recoveryWordHash719 recoveryWordsSet = payload.recoveryWordsSet720 rootKey = if (payload.rootKeyB64.isNotBlank()) unb64(payload.rootKeyB64)721 else generateRecoverableRootKey()722 _settings.value = store.loadSettings()723 VaultCrypto.zero(plaintext)724 return true725 } catch (_: Exception) {726 727 } finally {728 VaultCrypto.zero(material)729 VaultCrypto.zero(keyBytes)730 }731 }732733 734 735 736 slotA.rootKeyEscrow?.let { escrow ->737 val rk = unsealRootKeyEscrow(words, escrow) ?: return false738 decoyMode = false739 entryList = mutableListOf()740 duressConfigured = false741 recoveryWordHash = recoveryWordsHash(words)742 recoveryWordsSet = true743 rootKey = rk744 _settings.value = store.loadSettings()745 return true746 }747 return false748 }749750 751752753754755756757758759 suspend fun resetPasswordWithRecoveryWords(760 words: List<String>,761 newPassword: String,762 ): Boolean = mutex.withLock {763 if (rootKey == null) return false 764 return try {765 val newSalt = VaultCrypto.generateSalt()766 val newKey = VaultCrypto.deriveKey(newPassword, newSalt)767 val plaintext = VaultCodec.encode(currentPayload())768 val blob = b64(VaultCrypto.encrypt(plaintext, newKey, VaultCrypto.AAD_SLOT_A))769 val prior = store.loadSlotA()770 771 772 773 val valid = words.size == 12 && findInvalidWords(words).isEmpty()774 val freshRecovery = if (valid) buildRecoveryEscrow(words) ?: prior?.recoveryEscrow else prior?.recoveryEscrow775 val freshRootKey = if (valid) buildRootKeyEscrow(words) ?: prior?.rootKeyEscrow else prior?.rootKeyEscrow776 val next = StoredVault(777 version = prior?.version ?: 1,778 salt = b64(newSalt),779 blob = blob,780 recoveryEscrow = freshRecovery,781 rootKeyEscrow = freshRootKey,782 )783 if (!store.writeSlotA(next)) return false784 writeMatchingNoiseSlotB(next)785 store.stampLocalMutation()786 derivedKey?.let(VaultCrypto::zero)787 derivedKey = newKey788 VaultCrypto.zero(plaintext)789 publish()790 _isUnlocked.value = true791 true792 } catch (_: Exception) {793 false794 }795 }796797 798799800801802803 private fun buildRecoveryEscrow(words: List<String>): RecoveryEscrow? {804 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null805 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null806 val salt = VaultCrypto.generateSalt()807 val recoveryKey = deriveRecoveryKey(material, salt)808 val keyBytes = recoveryKey.encoded809 val plaintext = VaultCodec.encode(currentPayload())810 return try {811 val sealed = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY)812 RecoveryEscrow(salt = b64(salt), blob = b64(sealed))813 } finally {814 VaultCrypto.zero(material)815 VaultCrypto.zero(keyBytes)816 VaultCrypto.zero(plaintext)817 }818 }819820 821822823824825826827828 private fun buildRootKeyEscrow(words: List<String>): RootKeyEscrow? {829 if (words.size != 12 || findInvalidWords(words).isNotEmpty()) return null830 val rk = rootKey ?: return null831 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null832 val salt = VaultCrypto.generateSalt()833 val recoveryKey = deriveRecoveryKey(material, salt)834 val keyBytes = recoveryKey.encoded835 val plaintext = rootKeyToHex(rk).toByteArray(Charsets.US_ASCII)836 return try {837 838 val combined = VaultCrypto.encrypt(plaintext, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)839 val nonce = combined.copyOfRange(0, VaultCrypto.NONCE_SIZE)840 val ctTag = combined.copyOfRange(VaultCrypto.NONCE_SIZE, combined.size)841 val tag = ctTag.copyOfRange(ctTag.size - 16, ctTag.size)842 val ct = ctTag.copyOfRange(0, ctTag.size - 16)843 RootKeyEscrow(salt = b64(salt), wrappedRootKey = b64(ct), nonce = b64(nonce), tag = b64(tag))844 } finally {845 VaultCrypto.zero(material)846 VaultCrypto.zero(keyBytes)847 VaultCrypto.zero(plaintext)848 }849 }850851 852 private fun unsealRootKeyEscrow(words: List<String>, escrow: RootKeyEscrow): ByteArray? {853 val material = runCatching { wordsToRootKeyMaterial(words) }.getOrNull() ?: return null854 val recoveryKey = deriveRecoveryKey(material, unb64(escrow.salt))855 val keyBytes = recoveryKey.encoded856 return try {857 val combined = unb64(escrow.nonce) + unb64(escrow.wrappedRootKey) + unb64(escrow.tag)858 val pt = VaultCrypto.decrypt(combined, keyBytes, VaultCrypto.AAD_RECOVERY_ROOTKEY)859 hexToRootKey(String(pt, Charsets.US_ASCII))860 } catch (_: Exception) {861 null862 } finally {863 VaultCrypto.zero(material)864 VaultCrypto.zero(keyBytes)865 }866 }867868 869870871872873874 private fun persistSlotAWithEscrows(875 recoveryEscrow: RecoveryEscrow?,876 rootKeyEscrow: RootKeyEscrow?,877 ): Boolean {878 val key = derivedKey ?: throw IllegalStateException("vault_locked")879 if (decoyMode) return false880 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")881 val plaintext = VaultCodec.encode(currentPayload())882 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))883 val next = prior.copy(884 blob = blob,885 recoveryEscrow = recoveryEscrow ?: prior.recoveryEscrow,886 rootKeyEscrow = rootKeyEscrow ?: prior.rootKeyEscrow,887 )888 if (!store.writeSlotA(next)) return false889 try {890 writeMatchingNoiseSlotB(next)891 } catch (e: Exception) {892 store.writeSlotA(prior)893 throw e894 }895 return true896 }897898 899900 private fun requireUnlocked() {901 if (derivedKey == null) throw IllegalStateException("vault_locked")902 }903904 905906907908909 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {910 if (!entry.isCard()) return entry911 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""912 return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)913 }914915 916917918919920921 private fun autoPurgeExpiredTrash() {922 val days = _settings.value.trashRetentionDays ?: return923 if (days <= 0) return924 val cutoff = System.currentTimeMillis() - days.toLong() * 24L * 60L * 60L * 1000L925 val before = entryList.size926 entryList.removeAll { it.deletedAt != null && it.deletedAt < cutoff }927 if (entryList.size != before) runCatching { persistSlotA(bumpVersion = false) }928 }929930 931 private fun currentPayload(): VaultPayload =932 VaultPayload(933 entries = entryList.toList(),934 duressConfigured = duressConfigured,935 rootKeyB64 = rootKey?.let(::b64) ?: "",936 recoveryWordHash = recoveryWordHash,937 recoveryWordsSet = recoveryWordsSet,938 )939940 941942943944945 private fun ensureRootKey(payload: VaultPayload) {946 if (payload.rootKeyB64.isNotBlank()) {947 rootKey = unb64(payload.rootKeyB64)948 return949 }950 rootKey = generateRecoverableRootKey()951 runCatching { persistSlotA(bumpVersion = false) }952 }953954 private fun publish() {955 _entries.value = entryList.filter { it.deletedAt == null }956 }957958 private fun sealSlotA(key: ByteArray, salt: ByteArray): StoredVault {959 val plaintext = VaultCodec.encode(currentPayload())960 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))961 return StoredVault(version = 1, salt = b64(salt), blob = blob)962 }963964 private fun writeMatchingNoiseSlotB(slotA: StoredVault) {965 966 967 968 val approxBytes = (slotA.blob.length * 3 / 4).coerceAtLeast(VaultCrypto.NONCE_SIZE + 16)969 val noise = ByteArray(approxBytes).also(rng::nextBytes)970 store.writeSlotB(b64(noise))971 }972973 974 private fun persistSlotA(bumpVersion: Boolean): Boolean {975 val key = derivedKey ?: throw IllegalStateException("vault_locked")976 977 if (decoyMode) {978 val prior = store.loadDecoySlot() ?: throw IllegalStateException("missing decoy slot")979 val plaintext = VaultCodec.encode(currentPayload())980 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))981 return store.writeDecoySlot(prior.copy(blob = blob))982 }983 val prior = store.loadSlotA() ?: throw IllegalStateException("missing slot A")984 val plaintext = VaultCodec.encode(currentPayload())985 val blob = b64(VaultCrypto.encrypt(plaintext, key, VaultCrypto.AAD_SLOT_A))986 val next = prior.copy(blob = blob)987 if (!store.writeSlotA(next)) return false988 989 990 try {991 writeMatchingNoiseSlotB(next)992 if (bumpVersion) store.stampLocalMutation()993 } catch (e: Exception) {994 store.writeSlotA(prior)995 throw e996 }997 return true998 }9991000 1001 private fun persistOrRollback(rollback: MutableList<VaultEntry>) {1002 try {1003 if (!persistSlotA(bumpVersion = true)) {1004 entryList = rollback1005 throw IllegalStateException("storage_write_failed")1006 }1007 } catch (e: Exception) {1008 entryList = rollback1009 throw e1010 }1011 publish()1012 }1013}